How to increase your coding performance by creating a JavaScript CLI

Image for post
Image for post
Photo by Franck V. on Unsplash

In our team we have quite a few repetitive tasks when creating new features for our Content Management System. For example when we create new modules for our GraphQL Service, each one follows the same basic structure with a schema, some resolvers and tests. Further there is a config file to let the system know there is a new module. So we always duplicate an older module, delete almost all the content and edit the names to fit our new module.

So what we really wanted was to automate all this boring stuff but couldn’t find any good tutorials on how to create and modify old files with a JavaScript CLI.

  • Reading user input in the command line
  • Reading and parsing a JavaScript file using babel
  • Creating a test file using the exports of this file

Setting up a new project

Start with creating a project by typing the following commands:

mkdir cli-tutorial && cd $_
yarn init -y

Now we have a fresh project and can start off with adding a couple of dependencies we are going to use.

  • inquirer: for user-interaction in the terminal
  • babel: parsing JavaScript-files to get the AST (Abstract Syntax Tree)
yarn add @babel/parser inquirer

Open up the package.json, edit anything you like to fit your needs and also add the the start-script.

{
...
"scripts": {
"start": "node index.js"
},
...
}

Furthermore let’s create a simple JavaScript file with some exported variables/functions/classes and let’s call it dummy.js:

const sum1 = 7
const firstname = 'John'
export const add = sum2 => sum1 + sum2
export const fullName = firstname + ' Doe'
export class Vector {
constructor(x, y) {
this.x = x
this.y = y
}
length() {
return Math.sqrt(Math.pow(this.x, 2) + Math.pow(this.y, 2))
}
}

Reading filename from user input

Now we want the user to choose a file from the current working directory. To do that three steps are necessary:

  1. Fetch all files that end with .js but not .test.js from the current directory.
  2. Show these files in a list in the terminal using inquirer.
  3. Wait for the user to select a file.

So we create an index.js and paste the following lines:

const fs = require('fs')
const inquirer = require('inquirer')
const run = async () => {
const directoryFiles = fs
.readdirSync(process.cwd())
.filter(file => file.endsWith('.js') &&
!file.endsWith('.test.js'))
const { filename } = await inquirer.prompt({
type: 'list',
name: 'filename',
choices: directoryFiles,
})
}
run()

Reading the content of the file

In order to get the file’s content we need to read it using the fs module which is pretty basic. Then we can use babel’s parser module to get the AST of the the code.

...
const { parse } = require('@babel/parser')
const run = async () => {
...
const content = fs.readFileSync(filename, 'utf-8')
const ast = parse(content, {
sourceType: 'module',
})
}

Setting the sourceType to module lets the parser know that it reads an ES6 file that uses import and export statements.

You can also visit http://www.astexplorer.com and paste our dummy.js to take a peek at what the AST looks like. There are some properties of type VariableDeclaration and some properties of type ExportNamedDeclaration.

Image for post
Image for post
AST of dummy.js (www.astexplorer.com)

In our use case we just need all the names of those ExportNamedDeclarations, so we add this function to index.js:

function getExports(body) {
const namedExports = body
.filter(item => item.type === 'ExportNamedDeclaration')
.map(item => item.declaration)
.reduce((total, current) => {
if (current.id) {
return [...total, current.id.name]
} else if (current.declarations) {
return [
...total,
...current.declarations.map(declaration => declaration.id.name),
]
}
}, [])
return namedExports
}

The reduce function takes care of the different structure of the first export compared to the other two.

Creating a test file

To generate new files let’s create create-test-file.js which will contain our test-template:

module.exports = {
createTestFile: ({ filename, imports }) => {
return `/* eslint-env jest */
import { ${imports.join(', ')} } from '${filename}'
describe('${filename}', () => {
${imports
.map(i => {
return `test('${i}', () => {
expect(true).toBeTruthy()
})`
})
.join('\r\n \r\n \t')}
})
`
},
}

Using a template-literal we can simply fill in our variables. At first we are going to import all exported properties from the original file. Then we create a test function for each of these properties.

Back to the index.js we now are able to use this template:

const { createTestFile } = require('./create-test-file')const run = async () => {
...
const exports = getExports(ast.program.body)
const testContent = createTestFile(
{ imports: exports, filename }
)
}

The last thing we need to do is to generate a filename for the test which in our case will be dummy.test.js. After that we can simply put the content into the file.

const run = async () => {
...
const testFilename = createTestFilename(filename)
fs.writeFileSync(testFilename, testContent)
}
function createTestFilename(originalName) {
const parts = originalName.split('.')
return parts.slice(0, -1).join('.')
+ '.test.' + parts[parts.length - 1]
}

Now let’s run our CLI by typing yarn start into the terminal.

Make the CLI globally available

To be able to run our script from anywhere in the terminal we need to add the following to the package.json:

{
...
"bin": {
"cli-tutorial": "./index.js"
},
}

We also have to add the following line to the very top of index.js:

#!/usr/bin/env node

Now let’s globally install the CLI by running npm i -g

GitHub repository

Senior Software Engineer @ Axel Springer National Media & Tech GmbH & Co. KG

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store