rolling your own Node CLI tools
Most modern libraries and frameworks have a command line interface that will make it easier to build your own applications using the particular framework. Look at Angular CLI or Polymer CLI for an idea of what I'm talking about.
The idea is that we can create a tool like a Yeoman generator without having to learn the way Yeoman works.
Getting started #
To start create a package.json
file.
npm init
We'll install the application's dependencies next. Note that as of NPM 5.0 we don't need to indicate we're saving them as dependecies (--save
) as this is assumed when you install a package.
npm install caporal colors prompt shelljs
Edit the package.json file so that it looks like the exmaple below.
Pay special attention to the bin
section of the package. we've created a scaffold script that will run index.js
.
{
"name": "scaffold",
"version": "1.0.0",
"main": "index.js",
"bin": {
"scaffold": "index.js"
},
"dependencies": {
"caporal": "^0.3.0",
"colors": "^1.1.2",
"prompt": "^1.0.0",
"shelljs": "^0.7.7"
}
}
Buildng the app #
At a minimum Caporal requires:
- a command
- one or more arguments
- an action to execute when we use the command
The example below will take a template and, optionally, a variant of the template we want to create. It will log the arguments and options to the console.
#!/usr/bin/env node
const prog = require('caporal');
prog
.version('1.0.0')
.command('create', 'Create a new application')
.argument('<template>', 'Template to use')
.option('--variant <variant>', 'Which <variant> of the template we\'ll create')
.action((args, options, logger) => {
console.log({
args: args,
options: options
});
});
prog.parse(process.argv);
To test the program use NPM's link to add the project to our global npm space without having to publish it to the NPM registry and download it back to our development workstation.
npm link
This will put your scripts in your path and allow you to run your script without prepending the full path.
scaffold --help
If we run the full command:
scaffold create node --variant mvc
We should get this in response:
{ args: { template: 'node' }, options: { variant: 'mvc' } }
I know, we don't really need to log the data we've just entered so we can change the action to point to a separate file (discussed below)
#!/usr/bin/env node
const prog = require('caporal');
const createCommand = require('./lib/create');
prog
.version('0.0.1')
.command('create', 'Create a new element')
.argument('<element>', 'Element template to use')
.option('--element-name <name>', 'What element we want to create')
.action(createCommand);
prog.parse(process.argv);
The create.js
file host all the application logic for the create command and is isolated from other commands and the Caporal logic itself. We're using Shelljs to run Unix commands in our project and prompt to get user input.
The commands will prompt the user, replace placeholder elements, and copy content from our template to the elements we create.
const prompt = require('prompt');
const shell = require('shelljs');
const fs = require('fs');
const colors = require("colors/safe");
// Set prompt as blue
prompt.message = colors.blue("Replace");
// Command function
module.exports = (args, options, logger) => {
const variant = options.variant || 'default';
const elementPath = `${__dirname}/../elements/${args.element}/${variant}`;
const localPath = process.cwd();
// File variables
const variables = require(`${elementPath}/_variables`);
// Copy Element
// If the element path doesn't exist, bail
if !(fs.existsSync(elementPath)) {
logger.error(`The requested element for ${args.element} wasn’t found.`)
process.exit(1);
} else {
// otherwise copy the files
logger.info('Copying files…');
shell.cp('-R', `${elementPath}/*`, localPath);
logger.info('✔ The files have been copied!');
}
// Remove variables file from the current directory
// since we only need it on the template directory
if (fs.existsSync(`${localPath}/_variables.js`)) {
shell.rm(`${localPath}/_variables.js`);
}
logger.info('Please fill the following values…');
// Ask for variable values
prompt.start().get(variables, (err, result) => {
// Remove MIT License file if another is selected
if (result.license !== 'MIT') {
shell.rm(`${localPath}/LICENSE`);
}
// Replace variable values in all files
shell.ls('-Rl', '.').forEach(entry => {
if (entry.isFile()) {
// Replace '[VARIABLE]` with the variable value
// from the prompt
variables.forEach(variable => {
shell.sed('-i', `\\[${variable.toUpperCase()}\\]`,
result[variable], entry.name);
});
// Insert current year in files
shell.sed('-i', '\\[YEAR\\]', new Date().getFullYear(),
entry.name);
}
});
logger.info('✔ Success!');
});
}
Building templates #
Now that we have the logic for creating elements, let's look at creating the templates we'll use as the original for each element.
Each element has its own template and we may have more than one set of templates for our application. The structure may look like this tree.
.
└─ elements
└─ default
└── publish-video
└── publish-content
└── publish-grid
└─ application
We'll look at the default element that will act as our default template. Each element will mirror the structure of this default element. The directory has the following structure:
.
├── LICENSE
├── _variables.js
├── lib
├── package.json
├── myElement.js
- LICENSE is our element's license. MIT by default
- _varaiables.js contains the items
- llib is a directory to hold any additional files we need
- package.json
- myElement.js
_variables.js
contains the variables we want to replace in our elements. The replacement and processing is handled in the create script so we'll just present the file as is.
// Variables to replace
//
// They are asked to the user as they appear here.
// User input will replace the placeholder values
// in the template files
module.exports = [
'name',
'version',
'description',
'author',
'license'
];
The package.json
files is an example of what the template looks like. Variables to be replaced match the names in _variables.js
.
{
"name": "[NAME]",
"version": "[VERSION]",
"description": "[DESCRIPTION]",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js",
"start:dev": "nodemon server.js"
},
"author": "[AUTHOR]",
"license": "[LICENSE]",
"dependencies": {
"dotenv": "^2.0.0",
"hapi": "^16.1.0",
"hoek": "^4.1.0"
},
"devDependencies": {
"nodemon": "^1.11.0"
}
}
myElement.js
is the core of the template. We create a V1 Custom Element and take advantage of features in the specification, working with observed attributes and reflecting changes in the attributes to the code and vice versa.
Custom Elements V1 use only ES2015 exclusively.
class myElement extends HTMLElement {
static get observedAttributes() {
return ['disabled'];
}
// A getter/setter for a disabled property.
get disabled() {
return this.hasAttribute('disabled');
}
set disabled(val) {
if (val) {
this.setAttribute('disabled', '');
} else {
this.removeAttribute('disabled');
}
}
// Can define constructor arguments if you wish.
constructor() {
// If you define a constructor, always call super() first!
// This is specific to CE and required by the spec.
super();
}
attributeChangedCallback(name, oldValue, newValue) {
// When the drawer is disabled, update keyboard/screen reader behavior.
if (this.disabled) {
this.setAttribute('tabindex', '-1');
this.setAttribute('aria-disabled', 'true');
} else {
this.setAttribute('tabindex', '0');
this.setAttribute('aria-disabled', 'false');
}
}
}
// Associate the custom element with the class we just created
customElements.define('my-element', myElement);
So now that we have it all together we can publish it to NPM if we so choose because we linked the CLI tool using NPM we can run it without uploading it.