How to build a command-line app in Node.js using TypeScript, Google Cloud Functions, and Firebase
Scotch.io taught me much of what I know in web development. Earlier this year, I came across an article titled “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, and npm work. 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), which will execute 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-managercd 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 -Snpm 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, the compiler searches for the
tsconfig.json
file starting in the current directory and continuing up the parent directory chain. - Invoking tsc with no input files and a
--project
(or just-p
) command line option that specifies the path of a directory containing atsconfig.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 -Snpm i chalk -S && npm i @types/chalk -Dnpm i commander -S && npm i @types/commander -Dnpm i inquirer -S && npm i @types/inquirer -Dnpm i ora -S && npm i @types/ora -Dnpm 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.tstouch questions.tstouch logic.tstouch 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. 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 file.
import '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 set up 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 firefunctionscd firefunctions
To initialize your project:
- Run
firebase login
to log in to Firebase via the browser and authenticate the CLI tool. - 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 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 of 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 of 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). 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, 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 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 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
The next issue after we complete the above is how 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 the user with the questions passed to the inquirer. It returns a promise, answers, which are 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 and 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
- Grammarly — for proof-reading.
- Scotch Development — for providing a platform where newbies in programming can learn new tech.
- Christian Nwamba and Chris Sevilleja— your articles at Scotch.iointroduced me to the latest web frameworks and how they are used.
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.
This post is originally published by the author here. This version has been edited for clarity and may appear different from the original post.