Writing Node.js Command Line Tools

Glen Arrowsmith
Mar 2015
twitter.com/garrows

Why?

  • Cross Platform
  • NPM
  • JavaScript
  • 'nuff said

LookoutWorld

#!/usr/bin/env node

console.log('Lookout World');
mkdir lookout-world
cd lookout-world

npm init --yes

vim index.js
node ./index.js

chmod 700 index.js

./index.js

package.json

{
  "name": "lookout-world",
  "...":"...",

  "bin": {
    "lookout-world": "index.js",
    "low": "index.js"
  },
  "preferGlobal": "true"
}
lookout-world
npm link
low
npm publish
npm install -g lookout-world

Test Everything Always!

npm install --save-dev mocha should
vim test.js
var child_process = require('child_process'),
  should = require('should');

describe('Lookout World', function() {
  describe('printing', function() {
    it('should say "Lookout World" and not error', function(done) {
      var program = child_process.spawn('./index.js', []),
        gotOutput = false;
        
      program.stdout.on('data', function(data) {
        data.toString().should.eql('Lookout World\n');
        gotOutput = true;
      });

      program.on('exit', function(exitCode) {
        exitCode.should.eql(0);
        gotOutput.should.eql(true);
        done();
      });

      program.on('error', function(err) {
        should(err).not.exist;
      });
    });
  });
});

package.json

{
  "name": "lookout-world",
  "...": "...",
  
  "scripts": {
    "test": "mocha test.js"
  },

  "devDependencies": {
    "mocha": "^2.1.0",
    "should": "^5.0.1"
  }
}
npm test

Argument Parsing

The hard way

console.log(process.argv);

lookout-world BrisJS

[ 'node', '/usr/local/bin/low', 'BrisJS' ]
#!/usr/bin/env node

var subject = 'World';

if (process.argv.length == 3) {
  subject = process.argv[2];
}

console.log('Lookout ' + subject);

Test All The Things

    it('should say "Lookout CampJS" and not error', function(done) {

      var program = child_process.spawn('./index.js', ['CampJS']);

      program.stdout.on('data', function(data) {
        data.toString().should.eql('Lookout CampJS\n');
      });

      program.on('exit', function(exitCode) {
        exitCode.should.eql(0);
        done();
      });

      program.on('error', function(err) {
        should(err).not.exist;
      });
    });

Problems

lookout-world --help
lookout-world --bold BrisJS
lookout-world BrisJS --bold
lookout-world -b BrisJS
lookout-world --color blue BrisJS --bold World Internet

Argument Parsing

The easy way

npm install --save commander
#!/usr/bin/env node

var program = require('commander'),
  clc = require('cli-color'),
  message,
  formatter;

program
  .version('1.0.0')
  .usage('[options] <name ...>')
  .option('-b, --bold', 'Bold the name')
  .option('-c, --color <color>', 'Change the name to <color>', 'white')
  .parse(process.argv);

if (program.args.length === 0) {
  program.args.push('World');
}

for (var i = 0; i < program.args.length; i++) {
  formatter = clc[program.color];
  if (program.bold) {
    formatter = formatter.bold;
  }
  console.log(formatter('Lookout ' + program.args[i]));
}

Built In Flags

Built In Sanity Checks

Usage

Git Style Sub Commands

program
  .version('0.0.1')
  .command('install [name]', 'install one or more packages')
  .command('search [query]', 'search with optional query')
  .command('list', 'list packages installed')
  .parse(process.argv);
Looks for `scriptBasename-subcommand.js`

e.g. index-install.js

Streaming Input

//[SNIP]

program
  .version('1.0.0')
  .usage('[options] <name ...>')
  .option('-b, --bold', 'Bold the name')
  .option('-c, --color <color>', 'Change the name to <color>', 'white')
  .parse(process.argv);
  
var outputter = function(name) {
  formatter = clc[program.color];
  if (program.bold) {
    formatter = formatter.bold;
  }
  console.log(formatter('Lookout ' + name));
};

console.log('Enter names. Ctrl+d to end.');
process.stdin.setEncoding('utf8');
process.stdin.on('data', outputter);

var exiting = function() {
   console.log('Bytes Read', process.stdin.bytesRead);
};
process.stdin.on('end', exiting);

process.on('SIGINT', function() {
  process.stdin.end();
  exiting();
});

Usage

Usage - Piping

Prompting

// [SNIP]

var outputter = function(name) {
  formatter = clc[program.color];
  if (program.bold) {
    formatter = formatter.bold;
  }
  console.log(formatter('Lookout ' + name));
};

if (program.args.length === 0) {
  var rl = require('readline').createInterface({
    input: process.stdin,
    output: process.stdout
  });
  rl.question('What is your name? ', function(answer) {
    outputter(answer);
    rl.close();
  });
}

for (var i = 0; i < program.args.length; i++) {
  outputter(program.args[i]);
}

Testing Inputs

var clc = require('cli-color');
// [SNIP]

it('should prompt for a name and print it', function(done) {

  var program = child_process.spawn('./index.js', []);
  var askingQuestion = true;

  program.stdout.on('data', function(data) {
    if (askingQuestion) {
      data.toString().should.eql('What is your name? ');
      program.stdin.write('CampJS\n');
      program.stdin.end();
      askingQuestion = false;
    } else {
      data.toString().should.eql(clc.white('Lookout CampJS') + '\n');
    }
  });

  program.on('exit', function(exitCode) {
    exitCode.should.eql(0);
    done();
  });

  program.on('error', function(err) {
    done(err);
  });
});
// [SNIP]

Keypresses

var keypress = require('keypress');
 
// make `process.stdin` begin emitting "keypress" events 
keypress(process.stdin);
 
// listen for the "keypress" event 
process.stdin.on('keypress', function (ch, key) {
	console.log('got "keypress"', key);
	if (key && key.ctrl && key.name == 'c') {
		process.stdin.pause();
	}
});

process.stdin.setRawMode(true);
process.stdin.resume();

Window Size

process.stdout.on('resize', function() {
  console.log('screen size has changed!');
  console.log(process.stdout.columns + 'x' + process.stdout.rows);
});

Spinner

ar Spinner = require('cli-spinner').Spinner;
 
var spinner = new Spinner('processing.. %s');
spinner.setSpinnerString('|/-\\');
spinner.start();

setTimeout(function() {
  spinner.stop(true);
}, 2000);

How does the magic happen?

  • ANSI escape codes
  • Character literals
  • ASCII control characters

Beep 


console.log("\007"); // Beep

Colors


process.stdout.write('\x1b[31m'); // Red foreground
console.log('Red foreground');
process.stdout.write('\x1b[42m'); // Green background
console.log('Green background');
process.stdout.write('\x1b[39;49m'); // Reset colors
console.log('Reset');
but use cli-color instead

Roll your own spinner

var i = 0;
var readline = require('readline');
setInterval(function() {
  readline.clearLine(process.stdout, 0);
  readline.cursorTo(process.stdout, 0);
  process.stdout.write(i.toString());
  i++;
}, 100);
var i = 0;
setInterval(function() {
  process.stdout.write(i + '\r');
  i++;
}, 100);

Have some fun

#!/usr/bin/env node
var keypress = require('keypress'),
  readline = require('readline');

console.log('\033[2J'); // Clear the screen
keypress(process.stdin);
process.stdin.setRawMode(true);
process.stdin.resume();

process.stdin.on('keypress', function(ch, key) {
  switch (key.name) {
    case 'up':
      readline.moveCursor(process.stdout, -1, -1);
      process.stdout.write('🍪');
      break;
    case 'down':
      readline.moveCursor(process.stdout, -1, 1);
      process.stdout.write('🍪');
      break;
    case 'left':
      readline.moveCursor(process.stdout, -3, 0);
      process.stdout.write('🍪');
      break;
    case 'right':
      readline.moveCursor(process.stdout, 1, 0);
      process.stdout.write('🍪');
      break;
  }

  if (key && key.ctrl && key.name == 'c') {
    process.stdin.pause();
  }
});

Questions?

Writing Node.js Command Line Tools

By Glen Arrowsmith

Writing Node.js Command Line Tools

  • 3,164