npm-mini v0.2.1
Build Your Own npm CLI with TypeScript and Node.js from scratch
Perfect for beginners embarking on their TypeScript and Node.js journey. Step-by-step guide to building your own simple version of npm CLI with TypeScript and Node.js.
What You Will Accomplish:
- Setting up a project with
npm,TypeScript, andNode.js. - Integrating
ESLint v9+andPrettierand its configurations for code quality. - Configuring
webpack v5+as your module bundler. - Developing a CLI application using the
commanderpackage. - Learning and getting a deep understanding of how npm works.
Check it out
- Install via npm
npm install --save-dev npm-mini- Run the command
npmmlike npm
➜ npmm
...
➜ npmm initGetting Started with TypeScript: "Hello World"
Step-by-Step Project Initialization
1. Specifying the Node.js Version
First, ensure you're using the right version of Node.js. We'll use version 22.13.0 for this project.
echo '22.13.0' > .nvmrc
nvm use2. Initializing the npm Project
Create a new npm project by running the following command. The -y flag automatically fills the project details for you.
npm init -y3. Installing TypeScript and Node.js Type Definitions
To use TypeScript and access Node.js types, install TypeScript and the Node.js type definitions as development dependencies.
npm install --save-dev typescript @types/node
npx tsc --initRunning npx tsc --init generates a tsconfig.json file, which contains the TypeScript compiler configuration.
And let's create the src/index.ts and start with hello world.
After executing the above steps, your project directory should look like this:
.
├── README.md
├── node_modules
├── package-lock.json
├── package.json
├── src
│ └── index.ts
└── tsconfig.jsonConfigure TypeScript Compiler Options
After finish all the setup above, now we can try to add the compile script into the package.json and run it.
{
"scripts": {
"build": "tsc -p ."
},
}-p/--project: TypeScript looks for atsconfig.jsonfile in '.' directory
Since we didn't make any changes to the default tsconfig.json, it will generate the output, index.js, in the same path as the .ts files.
Let's update the tsconfig.json:
1. Setup rootDir and outDir
{
"compilerOptions": {
"rootDir": "src",
"outDir": "./dist",
},
"include": ["src/**/*"]
}rootDir: It controls the structure of output files after compilation (compare tobaseUrl), keep it same with your source code undersrc. This will also help to segregate other configurations, output files, and folders.outDir: Emit all files(Javascript and others) in one directoryincludeTellstscthat which files should be included to compile. The default behavior is to include all the*.ts,*.tsxfiles.
Here's a piece of advice based on my own experience: I suggest you try creating another folder, such as
test, at the same level assrc. Then, experiment with various path configurations (rootDir,outDir,include, etc.) to become more familiar with them.As the project grows and integrates more tools like linters and bundlers, the configuration of paths can become complex and easily confusing. Now is a good time to begin understanding and familiarizing yourself with these configurations.
2. Add Start Script to the package.json
{
"scripts": {
"build": "tsc -p .",
"start": "node dist/index.js"
},
}Now we can run the command npm run start to test the project.
You can try adding some folders and utility functions to export/import them and play around with them.
Note that the default module system in Node.js is
CommonJS. We will be using ECMAScript Modules (ESM), and we'll cover that later.
3. Introduce nodemon and tsx
npm install --save-dev nodemon tsxNow, we have a few things that need clarification:
tsc: This is the TypeScript compiler CLI tool, which is installed withtypescript.nodemon: A utility that monitors for any changes in your source and can automatically restart your application.tsx: It allows you to directly run TypeScript files (.ts) without having to compile them to JavaScript (.js) first. (Same thing likets-node,ts-node-dev)tsc -w/--watch: This command runs the TypeScript compiler in watch mode, the compiler watches for changes in your*.tsand automatically recompiles them to.js.
Here are some examples of how to set up these tools. You can run them on your own using these scripts:
{
"scripts": {
"start": "tsx src/index.ts",
"build": "tsc -p .",
"watch": "tsc -p . -w",
"dev": "nodemon --watch 'src/**/*.ts' --exec tsx src/index.ts"
},
}4. Configure ECMAScript Module (ESM) for Our TypeScript/Node.js Project
Look into the tsconfig.js:
{
"compilerOptions": {
"module": "commonjs",
}
}modulewas initially set ascommonjs, which meantscwill compile your source code and generate its output usingCommonJSmodule system.
- We update it to:
"module": "ESNext",, now the dist js files will use ESM syntax like import/export.
{
"compilerOptions": {
"module": "ESNext",
}
}- Then add this field
"type": "module",intopackage.json, by setting this, node.js will treat the js files with ESM when you runnodecommand.
{
"type": "module",
}It doesn't matter that you are writing ESM or CJS syntax (require/module.exports or import/export), as what module code is generated depends on the
moduleconfig in thetsconfig.js.But to note that, when you use syntax
const math = require('./utils/math');and compare to use ECMAScript Modules (ESM), you will need to add the.jsextension, likeimport math from './utils/math.js';. This is becausetsccompiles your source code without altering the file extensions, and Node.js, when handling ESM, requires the extension to properly manage the files.Fortunately, we don't usually need to do this. We will cover how to handle this situation using a bundler later on. For now, let's just add
.jsat the end if needed.
Integrating and Configuring ESLint and Prettier
npm install --save-dev eslint @eslint/js globals prettier eslint-config-prettier eslint-plugin-prettier typescript-eslintWe've installed several packages, including the core ESLint and Prettier packages, along with packages to integrate ESLint with Prettier and support for TypeScript.
If you're looking to simply use these tools, you can start with the necessary configuration files and adjust the rules.
Steps by Steps Guide: Install ESLint9, Integrate Prettier into project
Setting Up and Integrating Webpack
npm install --save-dev webpack webpack-cli @types/webpack ts-loaderNow let's begin configuring webpack. Create a file named webpack.config.mjs in the root folder and add the following basic configuration:
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default {
mode: 'development',
entry: {
index: path.resolve(__dirname, 'src/index.ts')
},
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
library: {
type: 'module'
},
clean: true
},
module: {
rules: [
{
test: /\.ts$/,
exclude: [/node_modules/],
loader: 'ts-loader'
}
]
},
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx']
},
experiments: {
outputModule: true
}
};Remember that previously, when importing packages, we used the '.js' extension, as in
import math from './utils/math.js';.With the
resolve.extensionsconfiguration in webpack, we can now omit the extension and import like this:import math from './utils/math';.
Then, you can replace your build script in package.json with the following line:
"scripts": {
"build": "webpack",
"watch": "webpack -w",
}After completing all the setup, the project structure will look something like this:
.
├── .gitignore
├── .nvmrc
├── .prettierrc
├── eslint.config.base.js
├── eslint.config.mjs
├── package-lock.json
├── package.json
├── src
│ └── index.ts
├── tsconfig.json
└── webpack.config.mjsSetup Command Line Applications Structure in Node.js
npm install commanderSetting Up the Configurations for CLI App
Before diving into the development, let's do some basic setup to ensure project is structured correctly:
1. Prepending a Shebang (#!/usr/bin/env node) to the Output File
We can use the webpack plugin BannerPlugin to add a banner to the top of each generated chunk.
For CLI applications, we can use this plugin to add a shebang line, which tells the system that the file should be executed with Node.js.
import webpack from 'webpack';
export default {
// Your existing webpack configuration
target: 'node', // Ensures webpack will compile code for usage in a Node.js environment
plugins: [
new webpack.BannerPlugin({
banner: '#!/usr/bin/env node',
raw: true, // This means the banner will be treated as a raw string
}),
],
};2. Adjusting tsconfig.json for Module Resolution
{
"compilerOptions": {
"moduleResolution": "Bundler"
}
}Module Resolution - moduleResolution
Creating CLI Program and Configuring the Entry Point
To start building your CLI, create a main file src/index.ts. This file will serve as the entry point to application.
import { program } from 'commander';
program
.name('npm-mini')
.description('An Simple Implementation of npm')
.version('0.0.0');
program
.command('init')
.description('Initialize a new npm project')
.action(() => console.log('Initialize a new npm project'));
program.parse(process.argv);Then we update our webpack config to specify the entry point the output structure
{
entry: index: path.resolve(__dirname, 'src/index.ts'),
output: {
filename: 'bin/cli.js',
path: path.resolve(__dirname, 'dist'),
library: {
type: 'module'
},
clean: true
},
}1. To make CLI executable
To ensure make CLI application is executable, when installed globally or within a project, we need to configure the bin field in package.json.
This field maps commands to local files, effectively telling npm which file should be run when the command is executed.
{
"bin": {
"npmm": "dist/bin/cli.js"
},
}2. Build the Source and Make CLI globally accessible
npm run build
chmod +x dist/bin/cli.js
npm link We'll use npm link to simulate an installation. Then find or create a new empty folder:
Note: Remember to ensure that the Node version used to develop the CLI app is the same as the version used where the app is deployed or linked.
nvm use 22.13.0
npm link npm-miniNow we can test the command:
➜ npm link npm-mini
added 1 package in 383ms
➜ npmm
Usage: npm-mini [options] [command]
An Simple Implementation of npm
Options:
-V, --version output the version number
-h, --help display help for command
Commands:
init Initialize a new npm project
help [command] display help for command
➜ npmm init
Initialize a new npm project3. Set up the Project Structure
Before developing the application, let's first establish the project structure and configure path aliases for better project management and development.
.
├── src
│ ├── commands
│ │ └── index.ts
│ ├── utils
│ │ └── index.ts
│ ├── index.ts
│ └── types.ts
├── tsconfig.json
└── webpack.config.mjsAs shown in the structure above, we will follow this pattern and develop our code. Let's continue configuring the path aliases:
- tsconfig.json
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@commands": ["src/commands/"],
"@utils": ["src/utils"],
"@types": ["src/types.ts"]
},
}
}- webpack.config.js
{
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx'],
alias: {
'@commands': path.resolve(__dirname, 'src/commands'),
'@utils': path.resolve(__dirname, 'src/utils'),
'@types': path.resolve(__dirname, 'src/types.ts')
}
},
}Implement the npm CLI Command by Command
npm init
npm init guides you through creating a package.json file for your project. It asks for project details such as name, version, and description, and you can either provide them or accept defaults. For a quick setup, npm init -y generates a package.json with default values without prompting for input.
1. Define the Basic Structure of package.json
src/types.ts
export interface PackageJson {
name: string;
version: string;
description: string;
scripts?: { [key: string]: string };
author: string;
license: string;
dependencies?: { [key: string]: string };
}2. Handle the package.json file
Let's create the utility method that handles reading/writing to package.json.
src/utils/package-json.ts
import { PackageJson } from '@types';
import path from 'path';
import fs from 'fs/promises';
export async function readPackageJson(
dir: string = process.cwd()
): Promise<PackageJson> {
const packagePath = path.join(dir, 'package.json');
const data = await fs.readFile(packagePath, 'utf8');
return JSON.parse(data);
}
export async function writePackageJson(
packageJson: PackageJson,
dir: string = process.cwd()
): Promise<void> {
const packagePath = path.join(dir, 'package.json');
await fs.writeFile(packagePath, JSON.stringify(packageJson, null, 2), 'utf8');
}Don't forget to re-export in index.ts
export { readPackageJson, writePackageJson } from './package-json';
readPackageJson(dir: string): Promise<PackageJson>: Reads thepackage.jsonfile from the specified directory (dir) or the current working directory (by default). It returns the contents as a JavaScript object.- Constructs the path to
package.jsonusingpath.join. - Reads the file content as a UTF-8 string with
fs.readFile. - Parses the string as JSON and returns the resulting object.
- Constructs the path to
writePackageJson(packageJson: PackageJson, dir: string): Promise<void>: Writes the provided JavaScript object (packageJson) to apackage.jsonfile in the specified directory (dir) or the current working directory (by default).- Constructs the path to
package.jsonsimilarly toreadPackageJson. - Converts the
packageJsonobject to a formatted JSON string usingJSON.stringify. - Writes the string to the file with
fs.writeFileusing UTF-8 encoding.
- Constructs the path to
Both functions utilize fs/promises for asynchronous file operations and path for handling file paths, allowing operations without blocking the main thread.
3. Create the Actual Logic of npm init
import { writePackageJson } from '@utils';
import { PackageJson } from '@types';
import { promisify } from 'util';
import readline from 'readline';
export async function init(): Promise<void> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const question = promisify(rl.question).bind(rl);
console.log(
'This utility will walk you through creating a package.json file.'
);
console.log(
'It only covers the most common items, and tries to guess sensible defaults.'
);
const packageJson: PackageJson = {
name: (await question('package name: '))! || 'my-project',
version: (await question('version: (1.0.0) '))! || '1.0.0',
description: (await question('description: '))! || '',
scripts: {
test: 'echo "Error: no test specified" && exit 1'
},
author: (await question('author: '))!,
license: (await question('license: (ISC) '))! || 'ISC'
};
console.log('\nAbout to write to package.json:\n');
console.log(JSON.stringify(packageJson, null, 2));
await writePackageJson(packageJson);
rl.close();
}init function defines an asynchronous function named that guides the user through the process of creating a package.json file. It utilizes the readline module for interactive command line input and output, and a utility function writePackageJson for writing the generated package.json content.
Set Up Readline Interface: It creates a
readlineinterface usingprocess.stdinfor input andprocess.stdoutfor output. This interface facilitates reading user inputs from the command line.Promisify
questionMethod: Thequestionmethod ofreadlineis promisified usingutil.promisify, allowing it to be used with async/await syntax for asynchronous operations. This is necessary because the originalquestionmethod uses callbacks for handling responses.Collecting Input: It prompts the user for various pieces of information required for a
package.jsonfile.Write to
package.json: Calls thewritePackageJsonfunction, passing the collected data as an argument to create or overwrite thepackage.jsonfile with the new content.Close Readline Interface: Finally, it closes the readline interface to clean up and end the input stream.
4. Add -y Option Generate Default package.json
src/index.ts
program
.command('init')
.description('Create a package.json file')
.option('-y, --yes', 'Generate it without having it ask any questions')
.action(init);src/commands/init.ts: refactor the code and implement the logic
export async function init(
options: { [key: string]: string } = {}
): Promise<void> {
const defaultPackageJson = {
name: 'my-project',
version: '1.0.0',
description: '',
main: 'index.js',
scripts: { test: 'echo "Error: no test specified" && exit 1' },
repository: undefined,
keywords: undefined,
author: '',
license: 'ISC'
};
if (options.yes) {
console.log('Generating package.json with default values...');
await writePackageJson(defaultPackageJson);
return;
}
console.log(
'This utility will walk you through creating a package.json file.'
);
console.log(
'It only covers the most common items, and tries to guess sensible defaults.'
);
const name = (await question('package name: (my-project)')) || 'my-project';
const version = (await question('version: (1.0.0) ')) || '1.0.0';
const description = (await question('description: ')) || '';
const main = (await question('entry point: (index.js) ')) || 'index.js';
const scripts = {
test:
(await question('test command: ')) ||
'echo "Error: no test specified" && exit 1'
};
const repository = (await question('git repository: ')) || undefined;
const keywordsInput = ((await question('keywords: ')) as string)
.split(',')
.map((kw) => kw.trim())
.filter((kw) => kw);
const keywords = keywordsInput?.length ? keywordsInput : undefined;
const author = (await question('author: ')) || '';
const license = (await question('license: (ISC) ')) || 'ISC';
const packageJson: PackageJson = {
name,
version,
main,
scripts,
author,
license,
description,
repository,
keywords
};
console.log('\nAbout to write to package.json:\n');
console.log(JSON.stringify(packageJson, null, 2));
await writePackageJson(packageJson);
}
async function question(query: string): Promise<string> {
return new Promise((resolve) => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
rl.question(`${query}: `, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}