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+
andPrettier
and its configurations for code quality. - Configuring
webpack v5+
as your module bundler. - Developing a CLI application using the
commander
package. - 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
npmm
like npm
➜ npmm
...
➜ npmm init
Getting 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 use
2. 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 -y
3. 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 --init
Running 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.json
Configure 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.json
file 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 directoryinclude
Tellstsc
that which files should be included to compile. The default behavior is to include all the*.ts
,*.tsx
files.
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 tsx
Now, 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*.ts
and 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",
}
}
module
was initially set ascommonjs
, which meantsc
will compile your source code and generate its output usingCommonJS
module 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 runnode
command.
{
"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
module
config 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.js
extension, likeimport math from './utils/math.js';
. This is becausetsc
compiles 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
.js
at 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-eslint
We'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-loader
Now 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.extensions
configuration 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.mjs
Setup Command Line Applications Structure in Node.js
npm install commander
Setting 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-mini
Now 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 project
3. 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.mjs
As 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.json
file 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.json
usingpath.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.json
file in the specified directory (dir
) or the current working directory (by default).- Constructs the path to
package.json
similarly toreadPackageJson
. - Converts the
packageJson
object to a formatted JSON string usingJSON.stringify
. - Writes the string to the file with
fs.writeFile
using 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
readline
interface usingprocess.stdin
for input andprocess.stdout
for output. This interface facilitates reading user inputs from the command line.Promisify
question
Method: Thequestion
method ofreadline
is promisified usingutil.promisify
, allowing it to be used with async/await syntax for asynchronous operations. This is necessary because the originalquestion
method uses callbacks for handling responses.Collecting Input: It prompts the user for various pieces of information required for a
package.json
file.Write to
package.json
: Calls thewritePackageJson
function, passing the collected data as an argument to create or overwrite thepackage.json
file 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());
});
});
}