diff --git a/.gitignore b/.gitignore index 3c3629e..93f1361 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules +npm-debug.log diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..858c214 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,16 @@ +language: node_js +node_js: + - "6" + - "6.1" + - "5.11" + - "iojs" +env: + - CXX=g++-4.8 +addons: + apt: + sources: + - ubuntu-toolchain-r-test + packages: + - g++-4.8 +after_script: + - npm run coveralls diff --git a/README.md b/README.md index 89d06ca..4237d82 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,12 @@ -# No(de) Shell +[![Build Status](https://travis-ci.org/piranna/nsh.svg?branch=master)](https://travis-ci.org/piranna/nsh) +[![Coverage Status](https://coveralls.io/repos/github/piranna/nsh/badge.svg?branch=master)](https://coveralls.io/github/piranna/nsh?branch=master) - +# Node SHell -Both **no shell** or **node shell** accurately describe `nsh`. +[![Built for NodeOS](http://i.imgur.com/pIJu2TS.png)](http://nodeos.github.io) -The goal of `nsh` is to provide a basic shell that will run without having `bash` or another process tidy things up first. - -The shell needs to be able to nest without mixing up who gets what keyboard input. -The shell should also be able to run interactive programs like *vim*. - -Right now node doesn't support doing proper job control, although I have a pull-request into libuv about that. - -- https://github.com/joyent/libuv/pull/934 - -Features are welcome, but may not be added until I'm sure the basics are stable. +`nsh` is a basic POSIX compliant shell that will run without having `bash` or +another process tidy things up first. It's also `require()`able and embedable on +other projects like [blesh](https://github.com/piranna/blesh), and has a +collection of basic commands as build-in functions running on the same shell +process powered by [Coreutils.js](https://github.com/piranna/Coreutils.js). diff --git a/lib/ast2js/_environment.js b/lib/ast2js/_environment.js new file mode 100644 index 0000000..4056abb --- /dev/null +++ b/lib/ast2js/_environment.js @@ -0,0 +1,85 @@ +'use strict' + +var environment = +{ + '?': 0, + PS1: '\\w > ', + PS2: '> ', + PS4: '+ ' +} + + +// +// Regular access +// + +const handler = +{ + ownKeys: function() + { + return Object.keys(environment) + }, + + get: function(_, prop) + { + var value = environment[prop] + + if(value === undefined) value = process.env[prop] + if(value == null) value = '' + + return value + }, + + set: function(_, prop, value) + { + if(value === undefined) return this.deleteProperty(_, prop) + + // Exported environment variable + const env = process.env + if(env[prop] !== undefined) + env[prop] = value + + // Local environment variable + else + environment[prop] = value + + return true + }, + + deleteProperty: function(_, prop) + { + // Local environment variable + var value = environment[prop] + if(value !== undefined) + delete environment[prop] + + // Exported environment variable + else + delete process.env[prop] + + return true + } +} + + +exports = new Proxy({}, handler) + + +// +// Stacked environment variables +// + +exports.push = function() +{ + process.env = {__proto__: process.env} + environment = {__proto__: environment} +} + +exports.pop = function() +{ + process.env = process.env.__proto__ + environment = environment.__proto__ +} + + +module.exports = exports diff --git a/lib/ast2js/_execCommands.js b/lib/ast2js/_execCommands.js new file mode 100644 index 0000000..20ef700 --- /dev/null +++ b/lib/ast2js/_execCommands.js @@ -0,0 +1,43 @@ +const eachSeries = require('async/eachSeries') + + +module.exports = execCommands + +const ast2js = require('./index') +const environment = require('./_environment') + + +// Always calculate dynamic `$PATH` based on the original one +const npmPath = require('npm-path').bind(null, {env:{PATH:process.env.PATH}}) + + +function execCommands(stdio, commands, callback) +{ + eachSeries(commands, function(command, callback) + { + // `$PATH` is dynamic based on current directory and any command could + // change it, so we update it previously to exec any of them + npmPath() + + command.stdio = stdio + + ast2js(command, function(error, command) + { + if(error) return callback(error) + + if(command == null) return callback() + + command.once('error', callback) + .once('exit', function(code, signal) + { + environment['?'] = code + environment['??'] = signal + + this.removeListener('error', callback) + + callback(code || signal) + }) + }) + }, + callback) +} diff --git a/lib/ast2js/_redirects.js b/lib/ast2js/_redirects.js new file mode 100644 index 0000000..fe4efd9 --- /dev/null +++ b/lib/ast2js/_redirects.js @@ -0,0 +1,73 @@ +var reduce = require('async').reduce + +var ast2js = require('./index') + + +function filterPipes(item) +{ + return item.type === 'pipe' +} + +function getSrcFd(stdio, fd) +{ + var result = stdio[fd] + + if(typeof result === 'string') return fd + + return result +} + +function setOutput(item) +{ + item.command.output = this +} + + +function iterator(stdio, redirect, callback) +{ + ast2js(redirect, function(error, value) + { + if(error) return callback(error) + + var type = redirect.type + switch(type) + { + case 'duplicateFd': + stdio[redirect.destFd] = getSrcFd(stdio, redirect.srcFd) + break; + + case 'moveFd': + stdio[redirect.dest] = getSrcFd(stdio, redirect.fd) + stdio[redirect.fd] = 'ignore' + break; + + case 'pipe': + stdio[1] = value + break; + + case 'redirectFd': + stdio[redirect.fd] = value + + if(redirect.op === '&>' + || redirect.op === '&>>') + stdio[2] = value + break; + + default: + return callback('Unknown redirect type "'+type+'"') + } + + callback(null, stdio) + }) +} + + +function redirects(stdio, array, callback) +{ + array.filter(filterPipes).forEach(setOutput, stdio.stdout) + + reduce(array, stdio.slice(), iterator, callback) +} + + +module.exports = redirects diff --git a/lib/ast2js/_spawnStream.js b/lib/ast2js/_spawnStream.js new file mode 100644 index 0000000..7da5101 --- /dev/null +++ b/lib/ast2js/_spawnStream.js @@ -0,0 +1,147 @@ +const EventEmitter = require('events') +const spawn = require('child_process').spawn +const stream = require('stream') + +const Duplex = stream.Duplex +const Readable = stream.Readable +const Writable = stream.Writable + + +function noop(){} + +/** + * Node.js `spawn` only accept streams with a file descriptor as stdio, so use + * pipes instead and connect the given streams to them. + */ +function wrapStdio(command, argv, options) +{ + argv = argv || [] + options = options || {} + + var stdio = options.stdio + if(!stdio) options.stdio = stdio = [] + + var stdin = stdio[0] + var stdout = stdio[1] + var stderr = stdio[2] + + // Wrap stdio + if(typeof stdin === 'string' || typeof stdin === 'number' + || stdin && stdin.constructor.name === 'ReadStream') + stdin = null + else + stdio[0] = 'pipe' + + if(typeof stdout === 'string' || typeof stdout === 'number' + || stdout && stdout.constructor.name === 'WriteStream') + stdout = null + else + stdio[1] = 'pipe' + + if(typeof stderr === 'string' || typeof stderr === 'number' + || stderr && stderr.constructor.name === 'WriteStream') + stderr = null + else + stdio[2] = 'pipe' + + // Create child process + var cp = spawn(command, argv, options) + + // Adjust events, pipe streams and restore stdio + if(stdin != null) + { + stdin.pipe(cp.stdin) + cp.stdin = null + } + if(stdout != null) + { + cp.stdout.pipe(stdout) + cp.stdout = null + } + if(stderr != null) + { + cp.stderr.pipe(stderr) + cp.stderr = null + } + + // Return child process + return cp +} + + +function spawnStream(command, argv, options) +{ + if(argv && argv.constructor.name === 'Object') + { + options = argv + argv = undefined + } + + options = options || {} + var stdio = options.stdio || [] + + var stdin = stdio[0] + var stdout = stdio[1] + var stderr = stdio[2] + + var cp = wrapStdio(command, argv, options) + + stdin = (stdin == null || stdin === 'pipe') ? cp.stdin : null + stdout = (stdout == null || stdout === 'pipe') ? cp.stdout : null + stderr = (stderr == null || stderr === 'pipe') ? cp.stderr : null + + var result + + // Both `stdin` and `stdout` are open, probably the normal case. Create a + // `Duplex` object with them so command can be used as a filter + if(stdin && stdout) result = Duplex() + + // Only `stdout` is open, use it directly + else if(stdout) result = Readable() + + // Only `stdin` is open, ensure is always 'only' `Writable` + else if(stdin) result = Writable() + + // Both `stdin` and `stdout` are clossed, or already redirected on `spawn` + else result = new EventEmitter() + + // Connect stdio streams + if(stdin) + { + result._write = stdin.write.bind(stdin) + result.once('finish', stdin.end.bind(stdin)) + } + + if(stdout) + { + result._read = noop + stdout.on ('data', result.push.bind(result)) + stdout.once('end' , result.push.bind(result, null)) + } + + // Use child process `exit` event instead of missing `stdout` `end` event + else + cp.once('exit', result.emit.bind(result, 'end')) + + if(stderr) + { + // Expose `stderr` so it can be used later. + result.stderr = stderr + + // Redirect `stderr` from piped command to our own `stderr`, since there's + // no way to redirect it to `process.stderr` by default as it should be. + // This way we can at least fetch the error messages someway instead of + // lost them... + var out_stderr = stdout && stdout.stderr + if(out_stderr) out_stderr.pipe(stderr) + } + + // Propagate process events + cp.once('error', result.emit.bind(result, 'error')) + cp.once('exit' , result.emit.bind(result, 'exit' )) + + return result +} + + +module.exports = spawnStream diff --git a/lib/ast2js/command.js b/lib/ast2js/command.js new file mode 100644 index 0000000..afc6aee --- /dev/null +++ b/lib/ast2js/command.js @@ -0,0 +1,162 @@ +const Domain = require('domain').Domain +const Duplex = require('stream').Duplex +const Readable = require('stream').Readable + +const flatten = require('array-flatten') +const map = require('async').map +const ToStringStream = require('to-string-stream') + +const ast2js = require('./index') +const redirects = require('./_redirects') +const spawnStream = require('./_spawnStream') + +const builtins = require('../builtins') + + +function noop(){} + +function wrapStdio(command, argv, options) +{ + var stdio = options.stdio + + var stdin = stdio[0] + var stdout = stdio[1] + var stderr = stdio[2] + + // Create a `stderr` stream for `error` events if none is defined + if(stderr === 'pipe') + { + stderr = new Readable({objectMode: true}) + stderr._read = noop + } + + + // Put `error` events on the `stderr` stream + var d = new Domain() + .on('error', stderr.push.bind(stderr)) + // [ToDo] Close `stderr` when command finish + + // Run the builtin command + command = d.run(command.bind(options.env), argv) + + // TODO stdin === 'ignore' + if(stdin !== 'pipe') + { + if(command.writeable) + stdin.pipe(command) + + stdin = null + } + + // TODO stdout === 'ignore' + if(stdout !== 'pipe') + { + if(command.readable) + { + if(stdout.objectMode) + command.pipe(stdout) + else + command.pipe(new ToStringStream()).pipe(stdout) + } + + stdout = null + } + + var result + + // TODO Check exactly what values can be `stdin` and `stdout`, probably we + // have here a lot of garbage code + if(stdin && stdout) + { + result = Duplex() + result._read = noop + result._write = stdin.write.bind(stdin) + + result.on('finish', stdin.end.bind(stdin)) + + stdout + .on('data', result.push.bind(result)) + .on('end' , result.emit.bind(result, 'end')) + } + + else if(stdin) + { + result = stdin + + command.once('finish', result.emit.bind(result, 'end')) + } + + else if(stdout) + { + result = command + + stdout.once('end', result.emit.bind(result, 'end')) + } + + else + result = command + + // Expose `stderr` so it can be used later. + if(stderr !== stdio[2]) result.stderr = stderr + + // Emulate process events + result.once('end', result.emit.bind(result, 'exit', 0, null)) + + return result +} + + +function command(item, callback) +{ + // Command + ast2js(item.command, function(error, command) + { + if(error) return callback(error) + + // Arguments + map(item.args, ast2js, function(error, argv) + { + if(error) return callback(error) + + // Globs return an array, flat it + argv = flatten(argv) + + // Redirects + redirects(item.stdio, item.redirects, function(error, stdio) + { + if(error) return callback(error) + + // Create command + var env = item.env + env.__proto__ = process.env + + var options = + { + env: env, + stdio: stdio + } + + // Builtins + var builtin = builtins[command] + if(builtin) return callback(null, wrapStdio(builtin, argv, options)) + + // External commands + try + { + command = spawnStream(command, argv, options) + } + catch(error) + { + if(error.code === 'EACCES') error = command+': is a directory' + + return callback(error) + } + + callback(null, command) + }) + }) + }) +} + + +module.exports = command diff --git a/lib/ast2js/commandSubstitution.js b/lib/ast2js/commandSubstitution.js new file mode 100644 index 0000000..d84baf9 --- /dev/null +++ b/lib/ast2js/commandSubstitution.js @@ -0,0 +1,36 @@ +var Writable = require('stream').Writable + +var environment = require('./_environment') +var execCommands = require('./_execCommands') + + +function commandSubstitution(item, callback) +{ + var buffer = [] + + var output = new Writable() + output._write = function(chunk, _, done) + { + buffer.push(chunk) + done() + } + + // Protect environment variables + environment.push() + + execCommands({output: output}, item.commands, function(error) + { + // Restore environment variables + environment.pop() + + // Restore (possible) changed current dir + process.chdir(environment['PWD']) + + if(error) return callback(error) + + callback(null, buffer.join('')) + }) +} + + +module.exports = commandSubstitution diff --git a/lib/ast2js/glob.js b/lib/ast2js/glob.js new file mode 100644 index 0000000..83ab371 --- /dev/null +++ b/lib/ast2js/glob.js @@ -0,0 +1,10 @@ +var globFunc = require('glob') + + +function glob(item, callback) +{ + globFunc(item.value, callback) +} + + +module.exports = glob diff --git a/lib/ast2js/ifElse.js b/lib/ast2js/ifElse.js new file mode 100644 index 0000000..8f7a3d3 --- /dev/null +++ b/lib/ast2js/ifElse.js @@ -0,0 +1,37 @@ +var detectSeries = require('async').detectSeries + +var ast2js = require('./index') + + +function ifElse(item, callback) +{ + function runTest(item, callback2) + { + ast2js(item.test, function(error, result) + { + if(error) return callback(error) + + callback2(result) + }) + } + + function execBody(block) + { + if(block) return ast2js(block.body, callback) + + if(item.elseBody) return ast2js(item.elseBody, callback) + + callback() + } + + + runTest(item, function(value) + { + if(value) return ast2js(item.body, callback) + + detectSeries(item.elifBlocks || [], runTest, execBody) + }) +} + + +module.exports = ifElse diff --git a/lib/ast2js/index.js b/lib/ast2js/index.js new file mode 100644 index 0000000..bf52424 --- /dev/null +++ b/lib/ast2js/index.js @@ -0,0 +1,30 @@ +function noop(item, callback) +{ + callback() +} + + +function ast2js(item, callback) +{ + ast2js[item.type](item, callback) +} + + +module.exports = ast2js + + +ast2js.command = require('./command') +ast2js.commandSubstitution = require('./commandSubstitution') +ast2js.duplicateFd = noop // Processed on `_redirects` file +ast2js.glob = require('./glob') +ast2js.ifElse = require('./ifElse') +ast2js.literal = require('./literal') +ast2js.moveFd = noop // Processed on `_redirects` file +ast2js.pipe = require('./pipe') +ast2js.processSubstitution = require('./processSubstitution') +ast2js.redirectFd = require('./redirectFd') +ast2js['until-loop'] = require('./until-loop') +ast2js.variable = require('./variable') +ast2js.variableAssignment = require('./variableAssignment') +ast2js.variableSubstitution = require('./variableSubstitution') +ast2js['while-loop'] = require('./while-loop') diff --git a/lib/ast2js/literal.js b/lib/ast2js/literal.js new file mode 100644 index 0000000..bc67434 --- /dev/null +++ b/lib/ast2js/literal.js @@ -0,0 +1,7 @@ +function literal(item, callback) +{ + callback(null, item.value) +} + + +module.exports = literal diff --git a/lib/ast2js/pipe.js b/lib/ast2js/pipe.js new file mode 100644 index 0000000..41b71b5 --- /dev/null +++ b/lib/ast2js/pipe.js @@ -0,0 +1,10 @@ +var command = require('./command') + + +function pipe(item, callback) +{ + command(item.command, callback) +} + + +module.exports = pipe diff --git a/lib/ast2js/processSubstitution.js b/lib/ast2js/processSubstitution.js new file mode 100644 index 0000000..a4e6628 --- /dev/null +++ b/lib/ast2js/processSubstitution.js @@ -0,0 +1,57 @@ +const fs = require('fs') +const tmpdir = require('os').tmpdir + +const mkfifo = require('mkfifo').mkfifo +const uuid = require('uuid').v4 + +const environment = require('./_environment') +const execCommands = require('./_execCommands') + + +function processSubstitution(item, callback) +{ + const path = tmpdir()+'/'+uuid() + + mkfifo(path, 0600, function(error) + { + if(error) return callback(error) + + // Protect environment variables + environment.push() + + function onExecuted(error) + { + stream.close() + + // Restore environment variables + environment.pop() + + // Restore (possible) changed current dir + process.chdir(environment['PWD']) + + if(error) console.trace(error) + } + + + if(item.readWrite === '<') + var stream = fs.createWriteStream(path) + .on('open', function() + { + execCommands({output: this}, item.commands, onExecuted) + }) + + else + var stream = fs.createReadStream(path) + .on('open', function() + { + execCommands({input: this}, item.commands, onExecuted) + }) + + stream.on('close', fs.unlink.bind(null, path)) + + callback(null, path) + }) +} + + +module.exports = processSubstitution diff --git a/lib/ast2js/redirectFd.js b/lib/ast2js/redirectFd.js new file mode 100644 index 0000000..2844d1a --- /dev/null +++ b/lib/ast2js/redirectFd.js @@ -0,0 +1,60 @@ +var fs = require('fs') + +var ast2js = require('./index') + + +function redirectFd(item, callback) +{ + function onError(error) + { + this.removeListener('open', onOpen) + + callback(error) + } + + function onOpen() + { + this.removeListener('error', onError) + + callback(null, this) + } + + + ast2js(item.filename, function(error, filename) + { + if(error) return callback(error) + + var result + + var op = item.op + switch(op) + { + case '<': + result = fs.createReadStream(filename) + break + + case '>': + case '&>': + result = fs.createWriteStream(filename, {flags: 'wx'}) + break + + case '>|': + result = fs.createWriteStream(filename) + break + + case '>>': + case '&>>': + result = fs.createWriteStream(filename, {flags: 'a'}) + break + + default: + return callback('Unknown redirectFd op "'+op+'"') + } + + result.once('error', onError) + result.once('open' , onOpen) + }) +} + + +module.exports = redirectFd diff --git a/lib/ast2js/until-loop.js b/lib/ast2js/until-loop.js new file mode 100644 index 0000000..9139a9b --- /dev/null +++ b/lib/ast2js/until-loop.js @@ -0,0 +1,22 @@ +var during = require('async').during + +var ast2js = require('./index') + + +function until_loop(item, callback) +{ + function test(callback) + { + ast2js(item.test, function(error, value) + { + callback(error, !value) + }) + } + + during(test, + ast2js.bind(null, item.body), + callback) +} + + +module.exports = until_loop diff --git a/lib/ast2js/variable.js b/lib/ast2js/variable.js new file mode 100644 index 0000000..e2938e5 --- /dev/null +++ b/lib/ast2js/variable.js @@ -0,0 +1,10 @@ +const environment = require('./_environment') + + +function variable(item, callback) +{ + callback(null, environment[item.name]) +} + + +module.exports = variable diff --git a/lib/ast2js/variableAssignment.js b/lib/ast2js/variableAssignment.js new file mode 100644 index 0000000..7df3cdb --- /dev/null +++ b/lib/ast2js/variableAssignment.js @@ -0,0 +1,18 @@ +var ast2js = require('./index') +var environment = require('./_environment') + + +function variableAssignment(item, callback) +{ + ast2js(item.value, function(error, value) + { + if(error) return callback(error) + + environment[item.name] = value + + callback() + }) +} + + +module.exports = variableAssignment diff --git a/lib/ast2js/variableSubstitution.js b/lib/ast2js/variableSubstitution.js new file mode 100644 index 0000000..12a128f --- /dev/null +++ b/lib/ast2js/variableSubstitution.js @@ -0,0 +1,10 @@ +const environment = require('./_environment') + + +function variableSubstitution(item, callback) +{ + callback(null, environment[item.expression]) +} + + +module.exports = variableSubstitution diff --git a/lib/ast2js/while-loop.js b/lib/ast2js/while-loop.js new file mode 100644 index 0000000..cd7e6ba --- /dev/null +++ b/lib/ast2js/while-loop.js @@ -0,0 +1,14 @@ +var during = require('async').during + +var ast2js = require('./index') + + +function while_loop(item, callback) +{ + during(ast2js.bind(null, item.test), + ast2js.bind(null, item.body), + callback) +} + + +module.exports = while_loop diff --git a/lib/builtins.js b/lib/builtins.js new file mode 100644 index 0000000..2a77a3a --- /dev/null +++ b/lib/builtins.js @@ -0,0 +1,19 @@ +const coreutils = require('coreutils.js') + +const environment = require('./ast2js/_environment') + + +// `cd` is a special case, since it needs to change the shell environment, +// that's why we overwrite it + +const coreutils_cd = coreutils.cd + +function cd(argv) +{ + return coreutils_cd(argv, environment) +} + +coreutils.cd = cd + + +module.exports = coreutils diff --git a/lib/completer.js b/lib/completer.js new file mode 100644 index 0000000..268c8d5 --- /dev/null +++ b/lib/completer.js @@ -0,0 +1,196 @@ +const fs = require('fs') + +const pc = require('lib-pathcomplete') +const ps = require('lib-pathsearch') +const reduce = require('async/reduce') + +const builtins = require('./builtins') +const environment = require('./ast2js/_environment') + +const constants = fs.constants +const stat = fs.stat + + +const EMPTY_ITEM = 1 +const EMPTY_ENV_VAR = 2 + + +// +// Helper functions +// + +function filterEnvVars(item) +{ + return item && !item.includes('=') +} + +function filterNames(name) +{ + return name.substr(0, this.length) === this.toString() +} + +function getEnvVars(item) +{ + var vars = Object.getOwnPropertyNames(environment).filter(filterNames, item) + var env = Object.keys(process.env) .filter(filterNames, item) + + if(vars.length && env.length) vars.push('') + + return vars.concat(env) +} + +function isExecutable(stats) +{ + const mode = stats.mode + + return mode & constants.S_IXUSR && stats.uid === process.getuid() + || mode & constants.S_IXGRP && stats.gid === process.getgid() + || mode & constants.S_IXOTH +} + +function mapEnvVarsAsign(name) +{ + if(name) name += '=' + + return name +} + +function mapEnvVarsRef(name) +{ + if(name) name = '$'+name + + return name +} + + +// +// Completer functions +// + +function envVar(item, callback) +{ + const key = item.substr(1) + + if(!key) return callback(EMPTY_ENV_VAR) + + var result = getEnvVars(key).map(mapEnvVarsRef) + + // if there is only one environment variable, append a space after it + if(result.length === 1) result[0] += ' ' + + callback(null, [result, item]) +} + +function relativePath(item, is_arg, callback) +{ + pc(item, function(err, arr, info) + { + if(err) return callback(err) + + // [Hack] Add `.` and `..` entries + if(info.file === '.' ) arr.unshift('.', '..') + if(info.file === '..') arr.unshift('..') + + reduce(arr, {}, function(memo, name, callback) + { + const path = info.dir + name + + stat(path, function(error, stats) + { + if(error) return callback(error) + + memo[path] = stats + + callback(null, memo) + }) + }, + function(error, stats) + { + if(error) return callback(error) + + // user is typing the command, autocomplete it only against the + // executables and directories in the current directory + if(!is_arg) + arr = arr.filter(function(name) + { + const stat = stats[info.dir + name] + + return stat.isDirectory() || isExecutable(stat) + }) + + arr = arr.map(function(item) + { + // If completion is a directory, append a slash + if(stats[info.dir + item].isDirectory()) item += '/' + + return item + }) + + // There's just only one completion and it's not a directory, append it a + // space + if(arr.length === 1 && arr[0][arr[0].length-1] !== '/') arr[0] += ' ' + + callback(null, [arr, info.file]) + }) + }) +} + + +/** + * auto-complete handler + */ +function completer(line, callback) +{ + const split = line.split(/\s+/) + const item = split.pop() + const is_arg = split.filter(filterEnvVars).length + + // avoid crazy auto-completions when the item is empty + if(!item && !is_arg) return callback(EMPTY_ITEM) + + // Environment variables + if(item[0] === '$') return envVar(item, callback) + + // Relative paths and arguments + if(item[0] === '.' || is_arg) return relativePath(item, is_arg, callback) + + // Commands & environment variables + ps(item, environment.PATH.split(':'), function(err, execs) + { + if(err) return callback(err) + + // Builtins + var names = Object.keys(builtins).filter(filterNames, item) + + // Environment variables + var envVars = getEnvVars(item).map(mapEnvVarsAsign) + + // Current directory + relativePath(item, true, function(err, entries) + { + if(err) return callback(err) + + entries = entries[0] + + // Compose result + if(names.length && execs.length) names.push('') + var result = names.concat(execs) + + if(result.length && envVars.length) result.push('') + result = result.concat(envVars) + + if(result.length && entries.length) result.push('') + result = result.concat(entries) + + // if there is only one executable, append a space after it + const result0 = result[0] + const type = result0[result0.length-1] + if(result.length === 1 && type !== '=' && type !== '/') result[0] += ' ' + + callback(null, [result, item]) + }) + }) +} + + +module.exports = completer diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..027930e --- /dev/null +++ b/lib/index.js @@ -0,0 +1,130 @@ +const inherits = require('util').inherits +const Interface = require('readline').Interface + +const decode = require('decode-prompt') +const parse = require('shell-parse') + +const _completer = require('./completer') +const environment = require('./ast2js/_environment') +const execCommands = require('./ast2js/_execCommands') + + +function onError(error) +{ + console.error(error) + + return this.prompt() +} + + +function Nsh(stdio, completer, terminal) +{ + if(!(this instanceof Nsh)) return new Nsh(stdio, completer, terminal) + + const stdin = stdio[0] + + Nsh.super_.call(this, stdin, stdio[1], + (completer || completer === false) ? completer : _completer, + terminal) + + + var self = this + + var input = '' + + function execCommandsCallback(error) + { + if(stdin.setRawMode) stdin.setRawMode(true) + stdin.resume() + + if(error) console.error(error) + + self.prompt() + } + + this.on('line', function(line) + { + input += line + + if(input === '') return this.prompt() + + try + { + var commands = parse(input) + } + catch(error) + { + if(error.constructor !== parse.SyntaxError) return onError.call(this, error) + + line = input.slice(error.offset) + + try + { + parse(line, 'continuationStart') + } + catch(error) + { + return onError.call(this, error) + } + + return this.prompt(true) + } + + if(stdin.setRawMode) stdin.setRawMode(false) + stdin.pause() + + execCommands(stdio, commands, execCommandsCallback) + }) + + + // + // Public API + // + + /** + * + */ + this.prompt = function(smallPrompt) + { + if(smallPrompt) + var ps = environment['PS2'] + + else + { + input = '' + + var ps = environment['PS1'] + } + + this.setPrompt(decode(ps, {env: environment})) + + // HACK Are these ones needed for builtins? We should remove them + this.line = '' + this.clearLine() + + Interface.prototype.prompt.call(this) + } + + + // Start acceoting commands + this.prompt() +} +inherits(Nsh, Interface) + + +Nsh.eval = function(stdio, line, callback) +{ + try + { + var commands = parse(line) + } + catch(error) + { + return callback(error) + } + + execCommands(stdio, commands, callback) +} + + +module.exports = Nsh diff --git a/nsh.js b/nsh.js deleted file mode 100755 index b4007ad..0000000 --- a/nsh.js +++ /dev/null @@ -1,190 +0,0 @@ -#!/usr/bin/env node - -var fs = require('fs'); -var glob = require('glob'); -var path = require('path'); -var rl = require('readline'); -var cp = require('child_process'); -var parse = require('lib-cmdparse'); -var ps = require('lib-pathsearch'); -var pc = require('lib-pathcomplete'); - -var state = { - "?" : null -} - -var execinfo_path = process.env.PATH.split(':'); - -// auto-complete handler -function completer (line, callback) { - var split = line.split(/\s+/); - var item = split.pop(); - var outs = []; - - // avoid crazy auto-completions when the line is empty - if (!line) return callback(1); - - // user is attempting to type a relative directory - var is_rel = item[0] === '.'; - - // user is typing first command - var is_first = split.length === 0; - - // if this is the first token on the line - // autocomplete it against commands in the search path - if (!is_rel && is_first) ps(item, execinfo_path, function (err, execs) { - // if there is only one executable, append a space after it - if (execs.length === 1) execs[0] = execs[0] + ' '; - callback(err, [execs, item]); - }); - - // if this is - else pc(item, function (err, arr, info) { - // if there is only one completion, and it's a directory - // automatically append a '/' to the completion - if (arr.length === 1) { - if (fs.statSync(info.dir + arr[0]).isDirectory()) - callback(err, [[arr[0] + '/'], info.file]); - else callback(err, [arr, info.file]); - } - else callback(err, [arr, info.file]); - }); - -} - -var iface = rl.createInterface({ - input : process.stdin, - output : process.stdout, - completer : completer -}); - -// visually indicate closed pipe with ^D -// otherwise exiting nested shells is confusing -iface.on('close', function () { - process.stdout.write('^D\n'); -}); - -// handle ^C like bash -iface.on('SIGINT', function () { - process.stdout.write('^C'); - iface.clearLine(); - prompt(); -}); - -process.on('SIGINT', function () { - // ignore -}); - -function readline(line){ - line = interpolate(line.trim(), process.env); - line = interpolate(line, state); - - if (line && line.length > 0) { - if (line.substring(0,2) === 'cd'){ - // cd is a native command - var dir = line.substring(2).trim(); - if (dir.length === 0) dir=process.env.HOME; - - // should not crash process with a bad 'cd' - try { - process.chdir(dir); - } catch (e) { - console.log(e); - } - - setImmediate(prompt); - }else{ - // other commands - run(line); - } - } else { - setImmediate(prompt); - } -} - -process.on('close',function(){ - process.exit(0); -}); - -// replace $VARs with environment variables -function interpolate(string, replace){ - return string.replace(/\$[^\s]+/g, function (key){ - var name = key.substring(1); - - var out; - if(replace[name] || replace[name] === 0){ - out = replace[name]; - } else { - out = key; - } - - return out; - }); -} - -function run(line){ - // allow for setting environment variables - // on the command line - var stanza = parse(line); - - // fallback to current environment - stanza.envs.__proto__ = process.env; - - // We must stop reading STDIN because we will soon - // be the background process group, which will raise - // errors when attempting to read/write to the TTY driver - process.stdin.setRawMode(false) - process.stdin.pause(); - - // Sub-Process - var args = stanza.args; - var exec = stanza.exec; - var proc = cp.spawn(exec,args,{ - cwd: process.cwd(), - env: stanza.envs, - - // Inerit the terminal - stdio: 'inherit' - }); - - // Have this shell resume control after the sub-process exists - function res(){ - process.stdin.setRawMode(true) - process.stdin.resume(); - prompt(); - } - - // catch exit code - function end(code, signal){ - // the $? variable should contain the exit code - state["?"] = code; - state["??"] = signal; - - res(); - } - - // catch errors - function err(err){ - res(); - } - - proc.on('error', err); - proc.on('exit', end); -} - -function prompt(){ - var prefix; - try { - prefix = process.cwd(); - } catch (e) { - prefix = "(none)"; - } - iface.question(prefix + " # ", function (line) { - readline(line); - }); -} - -// Main method -if(!module.parent){ - prompt(); -} diff --git a/package.json b/package.json index a768606..6928bb4 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,41 @@ { "name": "bin-nsh", - "version": "0.4.0", - "description": "Node/No Shell", + "version": "0.5.2", + "description": "Node SHell", "author": "Jacob Groundwater ", + "contributors": [ + "Jesús Leganés Combarro 'piranna' " + ], "license": "MIT", + "main": "lib", "bin": { - "nsh": "nsh.js" + "nsh": "server.js" + }, + "scripts": { + "coveralls": "easy-coveralls", + "test": "mocha" }, "dependencies": { - "lib-cmdparse": "~0.1.0", - "glob": "~3.2.7", - "lib-pathcomplete": "0.0.1", - "lib-pathsearch": "~0.1.0", - "mkdirp": "~0.3.5" + "array-flatten": "^2.1.1", + "async": "^2.4.0", + "concat-stream": "^1.6.0", + "coreutils.js": "github:piranna/coreutils.js", + "decode-prompt": "^0.0.2", + "glob": "~7.1.1", + "lib-pathcomplete": "piranna/node-lib-pathcomplete", + "lib-pathsearch": "piranna/node-lib-pathsearch", + "mkdirp": "~0.5.1", + "mkfifo": "^1.2.5", + "npm-path": "^2.0.3", + "shell-parse": "^0.0.2", + "to-string-stream": "^0.1.0", + "uuid": "^3.0.1" + }, + "devDependencies": { + "concat-stream": "^1.6.0", + "easy-coveralls": "0.0.1", + "mocha": "^3.4.1", + "string-to-stream": "^1.1.0", + "tmp": "0.0.31" } } diff --git a/server.js b/server.js new file mode 100755 index 0000000..b8fa2e1 --- /dev/null +++ b/server.js @@ -0,0 +1,131 @@ +#!/usr/bin/env node + +const readFile = require('fs').readFile + +const concat = require('concat-stream') +const eachSeries = require('async/eachSeries') + +const Nsh = require('.') + + +const PROFILE_FILES = ['/etc/profile', process.env.HOME+'/.profile'] +const stdio = [process.stdin, process.stdout, process.stderr] + + +function eval(data) +{ + // Re-adjust arguments + process.argv = process.argv.concat(argv) + + Nsh.eval(stdio, data, function(error) + { + if(error) onerror(error) + }) +} + +function interactiveShell() +{ + Nsh(stdio) + .on('SIGINT', function() + { + this.write('^C') + this.clearLine() + + this.prompt() + }) +} + +function loadCommands() +{ + switch(argv[0]) + { + case '-c': + argv.shift() + + const command_string = argv.shift() + if(!command_string) return onerror('-c requires an argument') + + const command_name = argv.shift() + if(command_name) process.argv[0] = command_name + + return eval(command_string) + + case '-s': + argv.shift() + + return process.stdin.pipe(concat(function(data) + { + eval(data.toString()) + })) + .on('error', onerror) + break + + default: + const command_file = argv.shift() + if(command_file) + return readFile(command_file, 'utf-8', function(error, data) + { + if(error) return onerror(error) + + eval(data) + }) + } + + + // + // Start an interactive shell + // + + // Re-adjust arguments + process.argv = process.argv.concat(argv) + + const ENV = process.env.ENV + if(!ENV) return interactiveShell() + + readFile(ENV, 'utf-8', function(error, data) + { + if(error) return onerror(error) + + Nsh.eval(stdio, data, function(error) + { + if(error) return onerror(error) + + interactiveShell() + }) + }) +} + +function onerror(error) +{ + console.error(process.argv0+': '+error) + process.exit(2) +} + + +// Get arguments + +const argv = process.argv.slice(2) +process.argv = [process.argv[1]] + +if(argv[0] != '-l') return loadCommands() + + +// Login shell + +argv.shift() + +eachSeries(PROFILE_FILES, function(file, callback) +{ + readFile(file, 'utf-8', function(error, data) + { + if(error) return callback(error.code !== 'ENOENT' ? error : null) + + Nsh.eval(stdio, data, callback) + }) +}, +function(error) +{ + if(error) return onerror(error) + + loadCommands() +}) diff --git a/test/fixture.txt b/test/fixture.txt new file mode 100644 index 0000000..8bd6648 --- /dev/null +++ b/test/fixture.txt @@ -0,0 +1 @@ +asdf diff --git a/test/index.js b/test/index.js new file mode 100644 index 0000000..0734022 --- /dev/null +++ b/test/index.js @@ -0,0 +1,380 @@ +const assert = require('assert') +const fs = require('fs') +const tty = require('tty') + +const concat = require('concat-stream') +const str = require('string-to-stream') +const tmp = require('tmp').file + +const spawnStream = require('../lib/ast2js/_spawnStream') + + +describe('spawnStream', function() +{ + it('no pipes', function(done) + { + var expected = ['aa','ab','bb'] + + const stdin = str(expected.join('\n')) + const stdout = concat(function(data) + { + expected.shift() + expected = expected.join('\n') + + assert.strictEqual(data.toString(), expected) + }) + + const result = spawnStream('grep', ['b']) + + assert.strictEqual(result.constructor.name, 'Duplex') + assert.ok(result.readable) + assert.ok(result.writable) + + stdin.pipe(result).pipe(stdout) + + result.on('end', done) + }) + + it('ignore stdio', function() + { + var stdio = ['ignore', 'ignore'] + + var result = spawnStream('ls', {stdio: stdio}) + + assert.strictEqual(result.constructor.name, 'EventEmitter') + assert.ok(!result.readable) + assert.ok(!result.writable) + }) + + it('set a command as `stdin` of another', function(done) + { + var expected = ['aa','ab','bb'] + + const argv = [expected.join('\n'), '-e'] + const echo = spawnStream('echo', argv, {stdio: ['ignore']}) + + assert.strictEqual(echo.constructor.name, 'Readable') + assert.ok(echo.readable) + assert.ok(!echo.writable) + + const stdout = concat(function(data) + { + expected.shift() + expected = expected.join('\n') + + assert.strictEqual(data.toString(), expected+'\n') + + done() + }) + + const grep = spawnStream('grep', ['b'], {stdio: [echo, stdout]}) + + assert.strictEqual(grep.constructor.name, 'EventEmitter') + assert.ok(!grep.readable) + assert.ok(!grep.writable) + }) + + it('set a command as `stdout` of another', function(done) + { + var expected = ['aa','ab','bb'] + + const stdout = concat(function(data) + { + expected.shift() + expected = expected.join('\n') + + assert.strictEqual(data.toString(), expected+'\n') + + done() + }) + + const grep = spawnStream('grep', ['b'], {stdio: [null, stdout]}) + + assert.strictEqual(grep.constructor.name, 'Writable') + assert.ok(!grep.readable) + assert.ok(grep.writable) + + const argv = [expected.join('\n'), '-e'] + const echo = spawnStream('echo', argv, {stdio: ['ignore', grep]}) + + assert.strictEqual(echo.constructor.name, 'EventEmitter') + assert.ok(!echo.readable) + assert.ok(!echo.writable) + + echo.stderr.pipe(process.stderr) + }) + + describe('pipe regular streams', function() + { + it('pipe stdin', function(done) + { + var expected = ['aa','ab','bb'] + + const stdin = str(expected.join('\n')) + const stdout = concat(function(data) + { + expected.shift() + expected = expected.join('\n') + + assert.strictEqual(data.toString(), expected) + + done() + }) + + const grep = spawnStream('grep', ['b'], {stdio: [stdin]}) + + assert.strictEqual(grep.constructor.name, 'Readable') + assert.ok(grep.readable) + assert.ok(!grep.writable) + + grep.pipe(stdout) + }) + + it('pipe stdout', function(done) + { + const expected = 'asdf' + + const stdout = concat(function(data) + { + assert.strictEqual(data.toString(), expected+'\n') + + done() + }) + + const echo = spawnStream('echo', [expected], {stdio: [null, stdout]}) + + assert.strictEqual(echo.constructor.name, 'Writable') + assert.ok(!echo.readable) + assert.ok(echo.writable) + + echo.stderr.pipe(process.stderr) + }) + + it('fully piped', function(done) + { + var expected = ['aa','ab','bb'] + + const stdin = str(expected.join('\n')) + const stdout = concat(function(data) + { + expected.shift() + expected = expected.join('\n') + + assert.strictEqual(data.toString(), expected) + + done() + }) + + var grep = spawnStream('grep', ['b'], {stdio: [stdin, stdout]}) + + assert.strictEqual(grep.constructor.name, 'EventEmitter') + assert.ok(!grep.readable) + assert.ok(!grep.writable) + }) + }) + + describe('pipe handler streams', function() + { + it('pipe stdin', function(done) + { + const expected = 'asdf' + + var stdin = new tty.ReadStream() + const stdout = concat(function(data) + { + expected.shift() + expected = expected.join('\n') + + assert.strictEqual(data.toString(), expected) + + done() + }) + + var result = spawnStream('ls', {stdio: [stdin]}) + + assert.strictEqual(result.constructor.name, 'Readable') + assert.ok(result.readable) + assert.ok(!result.writable) + + result.resume() + result.on('end', done) + }) + + it('pipe stdout', function(done) + { + const expected = 'asdf' + + const stdout = new tty.WriteStream() + + const echo = spawnStream('echo', [expected], {stdio: [null, stdout]}) + + assert.strictEqual(echo.constructor.name, 'Writable') + assert.ok(!echo.readable) + assert.ok(echo.writable) + + echo.on('end', done) + }) + + it('fully piped', function(done) + { + const stdin = new tty.ReadStream() + const stdout = new tty.WriteStream() + + const result = spawnStream('ls', {stdio: [stdin, stdout]}) + + assert.strictEqual(result.constructor.name, 'EventEmitter') + assert.ok(!result.readable) + assert.ok(!result.writable) + + result.on('end', function() + { + + done() + }) + }) + }) +}) + +describe('file descriptors', function() +{ + it('pipe stdin', function(done) + { + fs.open('test/fixture.txt', 'r', function(err, fd) + { + if(err) return done(err) + + function clean(err1) + { + fs.close(fd, function(err2) + { + done(err1 || err2) + }) + } + + const expected = 'asdf' + + const stdout = concat(function(data) + { + assert.strictEqual(data.toString(), expected+'\n') + + clean() + }) + + const echo = spawnStream('echo', [expected], {stdio: [fd]}) + + assert.strictEqual(echo.constructor.name, 'Readable') + assert.ok(echo.readable) + assert.ok(!echo.writable) + + echo.pipe(stdout) + }) + }) + + it('pipe stdout', function(done) + { + const expected = 'asdf' + + tmp(function(err, path, fd, cleanupCallback) + { + if(err) return done(err) + + function clean(err) + { + cleanupCallback() + done(err) + } + + const echo = spawnStream('echo', [expected], {stdio: [null, fd]}) + + assert.strictEqual(echo.constructor.name, 'Writable') + assert.ok(!echo.readable) + assert.ok(echo.writable) + + echo.on('end', function() + { + fs.readFile(path, 'utf-8', function(err, data) + { + if(err) return clean(err) + + assert.strictEqual(data, expected+'\n') + + clean() + }) + }) + }) + }) + + it('fully piped', function(done) + { + fs.open('test/fixture.txt', 'r', function(err, fdStdin) + { + if(err) return done(err) + + function clean1(err1) + { + fs.close(fdStdin, function(err2) + { + done(err1 || err2) + }) + } + + tmp(function(err, path, fdStdout, cleanupCallback) + { + if(err) return clean1(err) + + function clean2(err) + { + cleanupCallback() + clean1(err) + } + + const expected = 'asdf' + + const stdio = [fdStdin, fdStdout] + const echo = spawnStream('echo', [expected], {stdio}) + + assert.strictEqual(echo.constructor.name, 'EventEmitter') + assert.ok(!echo.readable) + assert.ok(!echo.writable) + + echo.on('end', function() + { + fs.readFile(path, 'utf-8', function(err, data) + { + if(err) return clean2(err) + + assert.strictEqual(data, expected+'\n') + + clean2() + }) + }) + }) + }) + }) +}) + + +// describe('command', function() +// { +// it('', function(done) +// { +// const item = +// { +// command:, +// args: [], +// stdio:, +// redirects: [], +// env: {} +// } +// +// command(item, function(command) +// { +// +// }) +// }) +// }) + +// if('inception', function() +// { +// +// })