Building and Publishing a CLI as an npm Package
Introduction
Creating a Command Line Interface (CLI) tool and publishing it as an npm package is an excellent way to share your work with the developer community. In this tutorial, we'll walk through the process of building a CLI that helps users scaffold Node.js projects, and then we'll publish it as an npm package.
Prerequisites
Before we begin, make sure you have the following installed:
- Node.js (version 12 or higher)
- npm (usually comes with Node.js)
- A code editor of your choice
Building the CLI
Step 1: Set up the project
First, let's create a new directory for our project and initialize it:
mkdir my-awesome-cli
cd my-awesome-cli
npm init -y
Step 2: Install dependencies
We'll need several packages to build our CLI. Let's install them:
npm install commander chalk fs-extra inquirer simple-git ora
Here's what each package does:
commander
: For parsing command-line argumentschalk
: For adding colors to console outputfs-extra
: An extension of thefs
module with additional functionalityinquirer
: For creating interactive command-line promptssimple-git
: For Git operationsora
: For creating beautiful terminal spinners
Step 3: Create the main CLI file
Let's break down the process of creating the main CLI file into several smaller steps:
Step 3-1: Create an index.js file
Create a new file called index.js
in the root of your project. This will be the main entry point for your CLI application.
Step 3-2: Make the file executable
At the very top of your index.js
file, add the following line:
#!/usr/bin/env node
This line is called a shebang or hashbang. It tells the system to execute this file using Node.js when the CLI command is run.
Step 3-3: Import necessary packages
Next, import all the required packages at the top of your file:
import { Command } from "commander";
import chalk from "chalk";
import fs from "fs-extra";
import path from "path";
import { simpleGit } from "simple-git";
import ora from "ora";
import inquirer from "inquirer";
import { execSync } from "child_process";
These imports bring in the functionality we need from our installed packages:
commander
for parsing command-line argumentschalk
for adding colors to console outputfs-extra
for enhanced file system operationspath
for working with file and directory pathssimple-git
for Git operationsora
for creating terminal spinnersinquirer
for interactive command-line promptsexecSync
from Node.js child_process module for running shell commands
Step 3-4: Initialize the command-line program
Initialize the command-line program using Commander:
const program = new Command();
This creates a new Command object, which we'll use to define our CLI's behavior.
Step 3-5: Define the CLI command
Set up the basic structure of your CLI command:
program
.version("1.0.0")
.argument("<project-name>", "name of the project")
.action(async (projectName) => {
// The main logic of your CLI will go here
});
This code:
- Sets the version of your CLI
- Defines an argument for the project name
- Sets up an async action function that will run when the command is executed
Step 3-6: Implement the main logic
Inside the .action()
function, implement the main logic of your CLI:
.action(async (projectName) => {
const projectPath = path.join(process.cwd(), projectName);
// Check if the project directory already exists
if (fs.existsSync(projectPath)) {
console.error(
chalk.red(`Error: Directory ${projectName} already exists.`)
);
process.exit(1);
}
// Prompt the user to select the template type
const answers = await inquirer.prompt([
{
type: "list",
name: "template",
message: "Which template would you like to use?",
choices: [
{ name: "TypeScript", value: "ts" },
{ name: "JavaScript", value: "js" },
],
default: "ts",
},
]);
// Determine the repository URL based on the user's selection
const repoUrl =
answers.template === "js"
? "https://github.com/MUKE-coder/nodejs-javascript-starter.git"
: "https://github.com/MUKE-coder/nodejs-typescript-starter-template.git";
// Create the project directory
fs.mkdirSync(projectPath);
// Set up the spinner for visual feedback
const spinner = ora(
`Downloading ${
answers.template === "js" ? "JavaScript" : "TypeScript"
} template...`
).start();
const git = simpleGit();
try {
// Clone the selected template
await git.clone(repoUrl, projectPath);
spinner.succeed(
`${
answers.template === "js" ? "JavaScript" : "TypeScript"
} template downloaded successfully!`
);
// Navigate into the project directory
process.chdir(projectPath);
// Install dependencies
spinner.start("Installing dependencies...");
execSync("npm install", { stdio: "inherit" });
spinner.succeed("Dependencies installed successfully!");
console.log(chalk.green(`Project ${projectName} is ready!`));
console.log(chalk.green(`cd ${projectName} and run npm run dev`));
} catch (error) {
spinner.fail("Failed to set up the project.");
console.error(chalk.red(error.message));
process.exit(1);
}
});
This code:
- Checks if the project directory already exists
- Prompts the user to choose a template
- Clones the selected template repository
- Installs dependencies
- Provides feedback throughout the process
Step 3-7: Parse command-line arguments
At the end of your index.js
file, add:
program.parse(process.argv);
This line tells Commander to parse the command-line arguments and execute the appropriate command.
Step 3-8: Final index.js file
After completing all the steps above, your index.js
file should look like this:
#!/usr/bin/env node
import { Command } from "commander";
import chalk from "chalk";
import fs from "fs-extra";
import path from "path";
import { simpleGit } from "simple-git";
import ora from "ora";
import inquirer from "inquirer";
import { execSync } from "child_process";
const program = new Command();
program
.version("1.0.0")
.argument("<project-name>", "name of the project")
.action(async (projectName) => {
const projectPath = path.join(process.cwd(), projectName);
if (fs.existsSync(projectPath)) {
console.error(
chalk.red(`Error: Directory ${projectName} already exists.`)
);
process.exit(1);
}
const answers = await inquirer.prompt([
{
type: "list",
name: "template",
message: "Which template would you like to use?",
choices: [
{ name: "TypeScript", value: "ts" },
{ name: "JavaScript", value: "js" },
],
default: "ts",
},
]);
const repoUrl =
answers.template === "js"
? "https://github.com/MUKE-coder/nodejs-javascript-starter.git"
: "https://github.com/MUKE-coder/nodejs-typescript-starter-template.git";
fs.mkdirSync(projectPath);
const spinner = ora(
`Downloading ${
answers.template === "js" ? "JavaScript" : "TypeScript"
} template...`
).start();
const git = simpleGit();
try {
await git.clone(repoUrl, projectPath);
spinner.succeed(
`${
answers.template === "js" ? "JavaScript" : "TypeScript"
} template downloaded successfully!`
);
process.chdir(projectPath);
spinner.start("Installing dependencies...");
execSync("npm install", { stdio: "inherit" });
spinner.succeed("Dependencies installed successfully!");
console.log(chalk.green(`Project ${projectName} is ready!`));
console.log(chalk.green(`cd ${projectName} and run npm run dev`));
} catch (error) {
spinner.fail("Failed to set up the project.");
console.error(chalk.red(error.message));
process.exit(1);
}
});
program.parse(process.argv);
This completes the creation of your main CLI file. The index.js
file now contains all the necessary code to run your CLI application, including argument parsing, user prompts, Git operations, and dependency installation.
Step 4: Modify package.json
Update your package.json
file to include the following:
{
"name": "create-desishub-app-nodejs",
"version": "1.0.1",
"description": "A CLI tool to scaffold Node.js projects",
"type": "module",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
"create-desishub-app": "./index.js"
},
"keywords": ["cli", "nodejs", "scaffold", "template"],
"author": "Your Name",
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",
"commander": "^12.1.0",
"inquirer": "^10.1.8",
"ora": "^8.0.1",
"simple-git": "^3.25.0",
"fs-extra": "^11.1.0"
}
}
Make sure to replace "Your Name" with your actual name or username.
Publishing the CLI as an npm Package
Requirements for Publishing
To publish a package on npm, you need:
- An npm account (create one at npmjs.com (opens in a new tab))
- A unique package name (check availability on npm)
- A
package.json
file with correct metadata - A README file explaining how to use your package
Modifying package.json for Publication
Ensure your package.json
file includes the following fields:
name
: A unique name for your packageversion
: Follow semantic versioning (e.g., "1.0.0")description
: A brief description of your packagemain
: The entry point of your package (usually "index.js")bin
: Specifies the command to run your CLIkeywords
: Relevant terms to help users find your packageauthor
: Your name or usernamelicense
: The license type (e.g., "MIT")
Creating a README File
Create a README.md
file in the root of your project with the following content:
# create-desishub-app-nodejs
`create-desishub-app-nodejs` is a CLI tool to quickly scaffold a new Node.js application with a customizable setup.
## Features
- Interactive prompts for creating a new Node.js project
- Supports JavaScript and TypeScript templates
- Initializes a Git repository and installs dependencies
## Installation
Install `create-desishub-app-nodejs` globally using npm:
\`\`\`bash
npm install -g create-desishub-app-nodejs
\`\`\`
## Usage
After installation, run the CLI command to create a new Node.js application:
\`\`\`bash
create-desishub-app app-name
\`\`\`
Follow the prompts to select your preferred template and options.
## License
MIT
Publishing to npm
-
Log in to your npm account in the terminal:
npm login
-
Publish your package:
npm publish
If successful, your package will be available on npm for others to install and use.
Using the CLI Tool
After publishing your CLI tool, users can install and run it in different ways. Let's explore how npm and npx work with our create-nodext-app
tool.
Understanding npm and npx
npm (Node Package Manager)
npm is the default package manager for Node.js. It allows you to install, share, and manage dependencies in your projects. When you install a package globally using npm, it becomes available as a command-line tool across your entire system.
To install our CLI tool globally:
npm install -g create-nodext-app
After global installation, users can run the tool directly:
create-nodext-app my-new-project
npx (Node Package Execute)
npx is a package runner tool that comes with npm (version 5.2+). It allows you to execute npm package binaries without installing them globally. This is particularly useful for running one-off commands or ensuring you're always using the latest version of a package.
To run our CLI tool using npx (without installing it globally):
npx create-nodext-app my-new-project
When you use npx:
- It first checks if the package is installed globally.
- If not, it temporarily downloads the latest version of the package.
- It then runs the specified command.
- After execution, the temporarily downloaded package is removed.
Benefits of Using npx
- No Global Installation: Users don't need to clutter their global npm space with packages they might use infrequently.
- Always Latest Version: npx always uses the latest version of the package, ensuring users have the most up-to-date features and bug fixes.
- One-Time Use: It's perfect for tools that you might only use occasionally or for trying out different packages without committing to an installation.
Configuring Your Package for npm and npx
To ensure your CLI tool works with both npm (global installation) and npx, you need to properly configure the bin
field in your package.json
:
{
"name": "create-nodext-app",
"version": "1.0.0",
"bin": {
"create-nodext-app": "./index.js"
}
// ... other fields
}
This configuration tells npm and npx that when someone runs create-nodext-app
, it should execute the index.js
file in your package.
Recommendation for Users
In your documentation, it's a good practice to provide instructions for both methods:
-
Global Installation:
npm install -g create-nodext-app create-nodext-app my-new-project
-
Using npx (no installation required):
npx create-nodext-app my-new-project
By providing both options, you give users the flexibility to choose the method that best suits their workflow. Some may prefer global installation for frequent use, while others might prefer using npx to always get the latest version without managing global packages.
Conclusion
Building and publishing a CLI tool as an npm package is a rewarding experience that can help many developers streamline their workflow. By following this guide, you've learned how to create a basic CLI tool, structure your project, and publish it to npm. Continue to iterate on your tool, gather feedback from users, and make improvements to create an even more valuable resource for the community.