How to build a command-line app in Node.js using TypeScript, Google Cloud Functions and Firebase

Chidume Nnamdi 🔥💻🎵🎮
codeburst
Published in
14 min readNov 10, 2017

--

Scotch.io taught me much of what I know in web development. Earlier this year, I came across an article “Build An Interactive Command-Line Application with Node.js” by Rowland Ekemezie. I was struck by the amount of knowledge I got from the article. I came to understand how much of these cli-apps like angular-cli, create-react-app, yeoman, npm works, So I decided to replicate his work and add more technologies to it here.

In this article, we are going to build a command-line contact management system in Node.js using TypeScript, Google Cloud Functions and Firebase.

Technologies

  • Node.js
  • TypeScript
  • Google Realtime Database (Firebase)
  • Google Cloud Functions
  • Commander.js
  • Inquirer.js

We will use commander.js for command line interfaces, inquirer.js for gathering input, Node.js as the core framework, Google Cloud Functions as FaaS (Function as a Service) that executes our functions and Firebase for data persistence.

Project setup

Make sure you have Node.js version ≥ 6. Let’s create a project directory and initialize it as a Node app.

mkdir contact-manager
cd contact-manager && npm init

Bringing TypeScript into the mix

As stated earlier we will be using TypeScript instead of the normal JavaScript. TypeScript is a strict syntactical superset of JavaScript and adds optional static typing to the language.

npm i typescript -S
npm i -g ts-node

As you can see, we installed typescript and also installed ts-node globally. ts-node is an executable, which allows TypeScript to be run seamlessly in a Node.js environment.

ts-node contact.ts

As you can see with ts-node, we can actually run TypeScript files (*.ts) without first compiling it to plain JavaScript, then use node contact.js to run the compiled file.

Setup tsconfig.json

The presence of a tsconfig.json file in a directory indicates that the directory is the root of a TypeScript project. The tsconfig.json file specifies the root files and the compiler options required to compile the project. A project is compiled in one of the following ways:

Using tsconfig.json

  • By invoking tsc with no input files, in which case the compiler searches for the tsconfig.json file starting in the current directory and continuing up the parent directory chain.
  • By invoking tsc with no input files and a --project (or just -p) command line option that specifies the path of a directory containing a tsconfig.json file, or a path to a valid .json file containing the configurations.

When input files are specified on the command line, tsconfig.json files are ignored.

Make your tsconfig.json look like this

{
"compilerOptions": {
"target": "es5",
"lib": [
"es2017","es2015","dom","es6"
],
"module": "commonjs",
"outDir": "./",
"sourceMap": false,
"strict": true
},
"include": [
"**.ts"
],
"exclude": [
"node_modules",
"firefunctions"
]
}

Install our npm module dependencies

We will need various Node modules to achieve our goal.

  • axios — a HTTP client library.
  • chalk — a Node module that allows devs to color shell console output.
  • commander — a command-line library for Node.js.
  • inquirer — A collection of common interactive command line user interfaces.
  • ora — a Node.js terminal spinner.
  • core-js — Modular standard library for JavaScript. It includes polyfills for es6, and es8.
npm i axios -S
npm i chalk -S && npm i @types/chalk -D
npm i commander -S && npm i @types/commander -D
npm i inquirer -S && npm i @types/inquirer -D
npm i ora -S && npm i @types/ora -D
npm i core-js -S && npm i @types/core-js -D

After the commands above are done, our package.json will look like this.

{
"name": "contact",
"version": "1.0.0",
"description": "",
...
"devDependencies": {
"@types/core-js": "^0.9.43",
"@types/ora": "^1.3.1",
"ts-node": "^3.3.0",
"typescript": "^2.5.3"
},
"dependencies": {
"@types/chalk": "^2.2.0",
"@types/commander": "^2.11.0",
"@types/inquirer": "0.0.35",
"axios": "^0.16.2",
"chalk": "^2.3.0",
"commander": "^2.11.0",
"core-js": "^2.5.1",
"inquirer": "^3.3.0",
"ora": "^1.3.0"
}
}

Now, TypeScript is now configured in our Node.js app. Let's create our project files.

Create TypeScript files

touch contact.ts
touch questions.ts
touch logic.ts
touch polyfills.ts

The project directory contact-manager should look like this.

- contact-manager
- /node_modules/
- tsconfig.json
- package.json
- contact.ts /** the entry point of our app **/
- questions.ts /** this contains arrays of questions **/
- polyfills.ts /** this contains our app polyfills **/
- logic.ts /** this holds the logic of our app **/

Setup polyfills

Going back to our tsconfig.json, you will see that we targeted the es5, es6, and es8 in the lib property of our tsconfig.json file.

...
"target": "es5",
"lib": ["es2017", "es2015", "dom", "es6"],
...

We need to configure our TS to use the ES2107 library, and since ES6 and ES8 are not yet well supported by all browsers we definitely want a polyfill. core-js does the job for us. We installed core-js and its @types/core-js earlier on, so we import the module core-js before our app is loaded. Let's put the following line in our polyfills.ts file.

/*** polyfills.ts ***/
//This file includes polyfills needed by TypeScript when using es2017, es6 or any above es5
// This file is loaded before the app. You can add your own extra polyfills to this fileimport 'core-js'

contact.ts is the entry point of our app, so we open it up and import our polyfills.ts file.

/*** contact.ts ***/
import './polyfills'

Now, we are set to use any ES8 or ES6 features. Before, we define our app logic. Let's first setup our Cloud Functions and Firebase.

What Are Cloud Functions for Firebase?

Firebase Cloud Functions run in a hosted, private, and scalable Node.js environment where you can run JavaScript code. You simply create reactive functions that trigger whenever an event occurs. Cloud functions are available for both Google Cloud Platform and Firebase (they were built on top of Google Cloud Functions).

Create a Firebase Cloud Function

Here, we will be using the HTTP trigger. Visit Google Cloud Platform to read more about Cloud Function triggers. Before we begin creating Cloud Functions, we have to install the Firebase tools.

Install the Firebase CLI

To begin to use Cloud Functions, we need the Firebase CLI (command-line interface) installed from npm. If you already have Node set up on your machine, you can install Cloud Functions with:

npm install -g firebase-tools

This command will install the Firebase CLI globally along with any necessary Node.js dependencies.

Initialize the Project

Let’s create a folder that will hold our Cloud Functions.

mkdir firefunctions
cd firefunctions

To initialize your project:

  1. Run firebase login to log in to Firebase via the browser and authenticate the CLI tool.
  2. Finally, run firebase init functions . This tool gives you an option to install dependencies with NPM. It is safe to decline if you want to manage dependencies in another way.

After these commands complete successfully, your project structure looks like this:

-contact-manager
-firefunctions/
--+.firebaserc
--+firebase.json
--+functions/
--+functions/package.json
--+functions/index.js
--+functions/node_modules/
-/node_modules/
-tsconfig.json
-package.json
-contact.ts /** the entry point of our app **/
-questions.ts /** this contains arrays of questions **/
-polyfills.ts /** this contains our app polyfills **/
-logic.ts /** this holds the logic of our app **/
  • .firebaserc: a hidden file that helps you quickly switch between projects with firebase use.
  • firebase.json: describes properties for your project.
  • functions/: this folder contains all the code for your functions.
  • functions/package.json: an NPM package file describing your Cloud Functions.
  • functions/index.js: the main source for your Cloud Functions code.
  • functions/node_modules/: the folder where all your NPM dependencies are installed.

Now, our Cloud Functions are set. They are written in plain JavaScript but we want to write it in TypeScript, then compile to JavaScript before deploying it to the Cloud.

Rename index.js to index.ts, then move into the functions folder.

cd functions 

Install typescript

npm i typescript -S

Create tsconfig.json

tsc init

Make it look like this

/** firefunctions/functions/tsconfig.json **/
{
"compilerOptions": {
"target": "es5",
"lib": ["es2017", "es2015", "dom", "es6"],
"module": "commonjs",
"outDir": "./",
"sourceMap": false,
"strict": true
},
"include": [
"index.ts"
],
"exclude": [
"node_modules"
]
}

Open package.json and modify the scripts tag section

/** firefunctions/functions/package.json **/
...
"scripts": {
"build": "tsc",
"watch": "tsc -w",
"deploy": " tsc && firebase deploy --only functions"
},
...

Import the Needed Modules and Initialize the App

We need two node modules: Cloud Functions and Admin SDK modules (these modules are already installed for us). So go to the index.ts and require these modules, and then initialize an admin app instance.

/** firefunctions/functions/index.ts **/import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin';
admin.initializeApp(functions.config().firebase)var contactsRef: admin.database.Reference = admin.database().ref('/contacts')

Code the Cloud Function

Now that the required modules for our project have been imported and initialized, let’s write our Cloud Functions code. As stated earlier, we are going to write functions that will be fired when an HTTP event occurs. We are going to write functions that will handle adding, updating, deleting and listing contacts.

  • addContact
  • deleteContact
  • updateContact
  • getContact
  • getContactList

Lets create the barebones of the following functions listed above

/** firefunctions/functions/index.ts **/import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin';
...
exports.addContact = functions.https.onRequest(...)
exports.deleteContact = functions.https.onRequest(...)exports.updateContact = functions.https.onRequest(...)exports.getContact = functions.https.onRequest(...)exports.getContactList = functions.https.onRequest(...)...

In the code above, each of the functions will execute when the corresponding names are called using cURL, an HTTP request or a URL request from your browser. Let’s try out a basic Cloud Function to see how it works.

Implementing A First Cloud Function

Open file index.ts and insert the following implementation:

/** firefunctions/functions/index.ts **/...
exports.helloWorld = functions.https.onRequest((request: express.Request, response: express.Response
) => {
response.send("Hello from Firebase!");
});
...

This is the most basic form of a Firebase Cloud Function implementation based on an HTTP trigger. The Cloud Function is implemented by calling the functions.https.onRequest method and handing over as the first parameter the function which should be registered for the HTTP trigger.

The function which is registered is very easy and consists of one line of code:

response.send("Hello from Firebase!");

Here the Response object is used to send a text string back to the browser so that the user gets a response and is able to see that the Cloud Function is working.

To try out the function we now need to deploy our project to Firebase.

npm run deploy

Note: The above compiles the index.ts to index.js, then deploys the JavaScript file index.js.

The deployment is started and you should receive the following response:

If the deployment has been completed successfully and you get back the function URL which now can be used to trigger the execution of the Cloud Function. Just copy and paste the URL into the browser and you should see the following output:

Note: Google Cloud Functions is a Node.js environment, that means you can run npm install --save package_name and use whatever package you want in your functions.

If you’re opening up the current Firebase project in the back-end and click on the link Functions you should be able to see the deployed helloWorld function in the Dashboard:

Let’s add some flesh to our functions.

/** firefunctions/functions/index.ts **/import 'core-js'
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin';
import * as cors from 'cors'
import * as express from 'express'
admin.initializeApp(functions.config().firebase)
var contactsRef: admin.database.Reference = admin.database().ref('/contacts')
/**
* @function {addContact}
* @return {Object}
* @parameter {express.Request}, {express.Response}
**/
exports.addContact = functions.https.onRequest((request: any, response: any) => {
cors()(request, response, () => {
contactsRef.push({
firstname: request.body.firstname,
lastname: request.body.lastname,
phone: request.body.phone,
email: request.body.email
})
})
response.send({'msg': 'Done', 'data': {
firstname: request.body.firstname,
lastname: request.body.lastname,
phone: request.body.phone,
email: request.body.email
}});

})
/**
* @function {getContactList}
* @return {Object}
* @parameter {express.Request}, {express.Response}
**/
exports.getContactList = functions.https.onRequest((request: any, response: any) => {
contactsRef.once('value', (data) => {
response.send({
'res': data.val()
})
})
})
const app: express.Application = express();
app.use(cors({origin: true}))
app.put('/:id', (req: any, res: any, next: any) => {
admin.database().ref('/contacts/' + req.params.id).update({
firstname: req.body.firstname,
lastname: req.body.lastname,
phone: req.body.phone,
email: req.body.email
})
res.send(req.body)
next()
})
app.delete('/:id', (req: any, res: any, next: any) => {
admin.database().ref('/contacts/' + req.params.id).remove()
res.send(req.params.id)
next()
})
app.get('/:id', (req: any, res: any, next: any) => {
admin.database().ref('/contacts/' + req.params.id).once('value' (data) => {
var sn = data.val()
res.send({
'res': sn
})
next()
},(err: any) => res.send({res: err})
)
})
/**
* @function {getContact}
* @return {Object}
* @parameter {express.Request}, {express.Response}
**/
exports.getContact = functions.https.onRequest((request: any, response: any) => {
return app(request, response)
})
/**
* @function {updateContact}
* @return {Object}
* @parameter {express.Request}, {express.Response}
**/
exports.updateContact = functions.https.onRequest((request: any, response: any) => {
return app(request, response)
})
/**
* @function {deleteContact}
* @return {Object}
* @parameter {express.Request}, {express.Response}
**/
exports.deleteContact = functions.https.onRequest((request: any, response: any) => {
return app(request, response)
})

Wow… We did a whole lot of things here. If you noticed, Express was brought into the play to handle RESTful requests. This is possible because as stated earlier Google Cloud Function is like a Docker container with a Node.js environment.

Deploy the Cloud Function

Let’s deploy our Cloud Function. Run this command for deployment:

npm run deploy

We are done with our Cloud Functions. Let’s move back into the contact-manager folder.

cd ../../

Define application logic

In this section, we define our controller functions that handles user input and calls the corresponding Cloud Function.

/** logic.ts **/
import axios from 'axios'
import chalk from 'chalk'
import * as ora from 'ora'
const url: string = "https://us-central1-myreddit-clone.cloudfunctions.net"export const addContact = (answers: any) => {
(async () => {
try {
const spinner = ora('Adding contact ...').start();
let response = await axios.post(`${url}/addContact`,answers)
spinner.stop()
console.log(chalk.magentaBright('New contact added'))
} catch (error) {
console.log(error)
}
})()
}
...

Basically, we defined the URL of our Cloud Function, which will be used based on the type of action that is to be performed. Axios is used to send request alongside the payload and to receive a response from our Cloud Function. We see here that our Cloud Function is the heart of our logic. It does the actual adding, updating, deleting, etc work and all our app does is to print the result on our console.

Define command-line arguments

We need a mechanism for accepting user inputs and passing it to our controller functions defined in the step above.

Commander.js comes to the rescue. commander.js is the complete solution for node.js command-line interfaces, inspired by Ruby’s commander.

/** contact.ts **/
...
commander
.version('1.0.0')
.description('Contact Management System')
commander
.command('addContact')
.alias('a')
.description('Add a contact')
.action(() => {
console.log(chalk.yellow('=========*** Contact Management System ***=========='))
inquirer.prompt(questions).then((answers) => actions.addContact(answers))
})
...

.command() Initialize a new Command.

The .action() callback is invoked when the command 'a' or 'addContact' is specified via ARGV, and the remaining arguments are applied to the function for access.

When the command arg is ‘*’, an un-matched command will be passed as the first arg, followed by the rest of ARGV remaining.

Runtime user inputs

Next issue after we complete the above is how do we get user inputs. Inquirer.js solves the issue for us.

Inquirer.js strives to be an easily embeddable and beautiful command line interface for Node.js (and perhaps the "CLI Xanadu").

Inquirer.js should ease the process of

  • providing error feedback
  • asking questions
  • parsing input
  • validating answers
  • managing hierarchical prompts

Note: Inquirer.js provides the user interface and the inquiry session flow. If you're searching for a full blown command line program utility, then check out commander, vorpal or args.

/** questions.ts **/
export let questions: Array<Object> = [
{
type: 'input',
name: 'firstname',
message: 'Enter first name'
},
{
type: 'input',
name: 'lastname',
message: 'Enter Lastname'
},
{
type: 'input',
name: 'phone',
message: 'Enter Phone Number'
},
{
type: 'input',
name: 'email',
message: 'Enter Your Email Address'
}
]
...

contact.ts

/** contact.ts **/
...
import { getIdQuestions, questions, updateContactQuestions } from './questions'
commander
.command('addContact')
.alias('a')
.description('Add a contact')
.action(() => {
console.log(chalk.yellow('=========*** Contact Management System ***=========='))
inquirer.prompt(questions).then((answers) => actions.addContact(answers))
})
...

The controller functions in questions.ts are imported here. The inquirer.prompt() launches the prompt interface (inquiry session) presenting to the user the questions passed to the inquirer. It returns a Promise, answers which is passed to our controller function addContact.

Make our app a shell Command

Now that our tool is complete, it is time to make it executable like a regular shell command. First, let’s add a shebang at the top of contact.ts, which will tell the shell how to execute this script.

/** contact.ts **/
#!/usr/bin/env node
import './polyfills'
import * as commander from 'commander'

Now, let’s configure the package.json to make it executable.


"description": "A command-line utility to manage contacts",
"main": "index.js",
"preferGlobal": true,
"bin": "./contact.js",

We have added a new property named bin, in which we have provided the name of the command from which contact.js will be executed.

We need to compile our scripts to JavaScript, we will modify our package.json.

/** package.json **/
...
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "nodemon --exec ts-node -- contact.ts",
"ts-node": "ts-node contact.ts",
"build": "tsc"
},
...

Run npm run build .

Now for the final step. Let’s install this script at the global level so that we can start executing it like a regular shell command.

npm install -g

Before executing this command, make sure you are in the same project directory. Once the installation is complete, you can test the command.

contact --help

This should print all of the available options that we get after executing node contact --help. Now you are ready to present your utility to the world.

One thing to keep in mind: During development, any change you make in the project will not be visible if you simply execute the contact command with the given options. If you run which contact, you will realize that the path of contact is not the same as the project path in which you are working. To prevent this, simply run npm link in your project folder. This will automatically establish a symbolic link between the executable command and the project directory. Henceforth, whatever changes you make in the project directory will be reflected in the contact command as well.

Source Code

Run our app

- add contact

- delete contact

- update contact

- contact list

Conclusion

We’ve barely scraped the surface of what’s possible with command line tooling in Node.js. As per Atwood’s Law, there are npm packages for elegantly handling standard input, managing parallel tasks, watching files, globbing, compressing, ssh, git, and almost everything else you did with Bash.

The source code for the example we built above is liberally licensed and available on Github .

If you found this useful, found a bug or have any other cool Node.js scripting tips, drop me a line on Twitter (I’m @ngArchangel).

Github repo

You can find the full source code in my Github repo.

Special thanks to

Social media

Feel free to reach out if you have any problems.

Follow me on Medium and Twitter to read more about TypeScript, JavaScript, and Angular.

--

--

JS | Blockchain dev | Author of “Understanding JavaScript” and “Array Methods in JavaScript” - https://app.gumroad.com/chidumennamdi 📕