Create a Code Generating CLI

Posted on December 26, 2018

Today I would like to walk through the procedure to create a code generating CLI. This is not a step by step tutorial, although we will be looking at the concepts involved.

What is a Code generating CLI, you ask? You might have surely used one in your career, some of the examples being create-react-app, express-generator, vue-cli or the amazing ng-cli.

All of these have a CLI interface that create a fully fledged application with the options passed to it. Today, we look to achieve something similar (but smaller) using the Gluegun CLI building toolkit.

Gluegun defines itself as a delightful toolkit for building Node-powered CLIs. If this is your first time, then you can read on features and why you might want to use it from their docs here. It is used in production by awesome tools like AWS Amplify and Ignite CLI.

What we are going to build

Tierney Cyren had tweeted this and things went out of control very soon with lots and lots of people creating their own cards. (Even I have my own, with npx boywithsilverwings) What we are trying to create today is a CLI to create your own npx card, because why the hell not!

I have a ready made CLI here that you can run with:

npm init profile-card card-name

Tip: npm allows you to skip the create part in your package name with npm init. For eg. npm init react-app

Recap on EJS

Before we start on Gluegun, we need to take a quick recap of ejs that we will be using for templating.

1<!-- Substitutes h2 contents with user.name value -->
2<h2><%= user.name %></h2>
3
4<!-- Includes user/show.ejs file and supplies user to it -->
5<%- include('user/show', {user: user}); %>
6
7<!-- enters the unescaped value into the tag-->
8<%- JSON.stringify(user) -%> `}

There is a lot more to it than that, but that is what we will be using for this tutorial.

ejs is not just used for templating HTML strings, it can be used in any environment, it being JSON, Javascript or CSS. This is what we will use to generate our code according to the template.

Getting started with Gluegun

Gluegun comes with a CLI (which Surprise! uses the Gluegun package) that helps us bootstrap a project really fast.

npx gluegun@next new create-card
cd create-card
npm link
create-card

What’s inside

1β”œβ”€β”€ node_modules
2β”œβ”€β”€ src
3β”œβ”€β”€ .gitignore
4β”œβ”€β”€ bin
5β”œβ”€β”€ docs
6β”œβ”€β”€ __tests__
7β”œβ”€β”€ yarn-lock.json
8β”œβ”€β”€ package.json
9└── readme.md

src contains all the code that we are going to edit. bin contains the cli file that is the root of the application.

Tip: This currently adds yarn.lock and package.lock.json to .gitignore by default, you might want to remove them.

Let’s looks inside the src now.

β”œβ”€β”€ commands
β”œβ”€β”€ extensions
β”œβ”€β”€ templates
β”œβ”€β”€ cli.js

Commands

The commands folder contain the commands you want the cli app to run. In our example, we will only have a single command, that also corresponds to the name of the app.

1// src/commands/create-card.js
2module.exports = {
3 name: 'create-card',
4 run: async toolbox => {
5 const { print } = toolbox;
6 print.info('Welcome to your CLI');
7 },
8};

The run function actually contains the code that will execute when you type in create-card in the console. Inside, you see that print is taken out of something called the toolbox. What all does this toolbox actually have? Well, a lot of things. We will look at some along the way. What print command does is to print whatever text you give it to the console. There are variety of formats for use, like print.info, print.warning, print.error etc. (just like console, you get the idea)

But you can see that writing all the code inside these run functions can get very tricky. This is where extensions helps with.

Extensions

Extensions are how we can create reusable functions and attach them to the toolbox.

1module.exports = toolbox => {
2 // A function to install packages present in package.json
3 function installPackages(props) {
4 const {
5 system: { which, spawn },
6 print: { info },
7 } = toolbox;
8
9 info('Starting package installation');
10 // get the path of npm installation, you can also run `which npm` in terminal to see the output
11 const npmPath = which('npm');
12
13 /*
14 1. Spawn a shell process that will navigate to the folder
15 2. Run npm install
16 3. Run npm run format (which is also defined in package.json)
17 */
18 return spawn(
19 `cd ${props.name} && ${npmPath} install && ${npmPath} run --quiet format`,
20 {
21 shell: true,
22 stdio: 'inherit',
23 stderr: 'inherit',
24 }
25 );
26 }
27 // Put the function into the toolbox
28 toolbox.installPackages = installPackages;
29};

You can now invoke an extension from inside a command like:

1// src/commands/create-card.js
2module.exports = {
3 name: 'create-card',
4 description: 'Create new profile card project',
5 // Create alias new, create, generate and n which will also work with this command
6 alias: ['new', 'create', 'generate', 'n'],
7 run: async toolbox => {
8 const {
9 parameters,
10 template: { generate },
11 print: { info },
12 installPackages,
13 fileSystem,
14 promptDetails,
15 }
16 // Get name of the folder to be created, accessible with parameters.first
17 const name = parameters.first;
18 /*
19 This is a custom extension that prompts for details from the user
20 https://github.com/BoyWithSilverWings/create-profile-card/blob/master/src/extensions/prompt-details.js
21 */
22 const details = await promptDetails({ name });
23 const props = { name, config };
24 }
25}

Not a fan of this technique, but may be it gets better with types.

Templates

Now, fast forward to the topic. How do we create the files that the user interacts with. That is what the templates folder does. Inside this folder you will find the required files that have an extra extension, .ejs This is what helps us inject user provided content into these files.

Now, all these files may not need content injection, for example .gitignore, but we include the extension so we can replace it by just iterating over all the files.

Here is an example of a file that needs injection:

1{
2 "name": "<%= props.name %>",
3 "version": "0.0.1",
4 "description": "<%= props.name %> Profile Card",
5 "bin": {
6 "<%= props.name %>": "bin/card.js"
7 },
8 "scripts": {
9 "format": "prettier --write **/*.{js,json} && standard --fix",
10 "lint": "standard"
11 },
12 "license": "MIT",
13 "dependencies": {
14 "boxen": "^2.1.0",
15 "chalk": "^2.4.1"
16 },
17 "devDependencies": {
18 "standard": "^12.0.1",
19 "prettier": "^1.12.1",
20 "jest": "^23.6.0"
21 }
22}

You can see that this files requires us to fill in the name with props.name where props is supposed to be passed into this function. This is same as the name that the user has already entered with create command. How do we pass this in?

1// Continuing src/commands/create-card.js
2const files = [
3 'bin/card.js.ejs',
4 'package.json.ejs',
5 'README.md.ejs',
6 'config.json.ejs',
7 '.gitignore.ejs',
8];
9
10const filesCopy = files.reduce((acc, file) => {
11 const template = `/${file}`;
12 // Where to copy this file to.
13 const target = `${props.name}/${file.replace('.ejs', '')}`;
14 /*
15 First argument is the template,
16 Second is the target, where to put the file
17 The third is the argument to be passed into the file
18 returns a promise
19 */
20 const gen = generate({ template, target, props });
21 return acc.concat([gen]);
22}, []);
23
24// Wait for all promises to resolve
25await Promise.all(filesCopy);
26// Set permissions for cli to execute
27filesystem.chmodSync(`${props.name}/bin/card.js`, '755');
28// Wait for installing packages, this is the custom extension written above
29await installPackages(props);

Once this is complete, you will have all the files copied to the directory of preference and ready.

bin/card.js contains the javascript that will be suppled to the user. It uses boxen to draw a box and chalk to output text in colors for the console. It reads user details from config.json and creates console output.

You can find the complete application here for reference.

Feel free to ping me at @agneymenon if you get stuck.