0.2.1 • Published 6 months ago

npm-mini v0.2.1

Weekly downloads
-
License
MIT
Repository
github
Last release
6 months ago

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:

  1. Setting up a project with npm, TypeScript, and Node.js.
  2. Integrating ESLint v9+ and Prettier and its configurations for code quality.
  3. Configuring webpack v5+ as your module bundler.
  4. Developing a CLI application using the commander package.
  5. 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 a tsconfig.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 to baseUrl), keep it same with your source code under src. This will also help to segregate other configurations, output files, and folders.
  • outDir: Emit all files(Javascript and others) in one directory
  • include Tells tsc 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 as src. 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 with typescript.
  • 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 like ts-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 as commonjs, which mean tsc will compile your source code and generate its output using CommonJS module system.
  1. We update it to: "module": "ESNext",, now the dist js files will use ESM syntax like import/export.
{
  "compilerOptions": {
    "module": "ESNext",
  }
}
  1. Then add this field "type": "module", into package.json, by setting this, node.js will treat the js files with ESM when you run node 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 the tsconfig.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, like import math from './utils/math.js';. This is because tsc 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';

  1. readPackageJson(dir: string): Promise<PackageJson>: Reads the package.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 using path.join.
    • Reads the file content as a UTF-8 string with fs.readFile.
    • Parses the string as JSON and returns the resulting object.
  2. writePackageJson(packageJson: PackageJson, dir: string): Promise<void>: Writes the provided JavaScript object (packageJson) to a package.json file in the specified directory (dir) or the current working directory (by default).

    • Constructs the path to package.json similarly to readPackageJson.
    • Converts the packageJson object to a formatted JSON string using JSON.stringify.
    • Writes the string to the file with fs.writeFile using UTF-8 encoding.

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.

  1. Set Up Readline Interface: It creates a readline interface using process.stdin for input and process.stdout for output. This interface facilitates reading user inputs from the command line.

  2. Promisify question Method: The question method of readline is promisified using util.promisify, allowing it to be used with async/await syntax for asynchronous operations. This is necessary because the original question method uses callbacks for handling responses.

  3. Collecting Input: It prompts the user for various pieces of information required for a package.json file.

  4. Write to package.json: Calls the writePackageJson function, passing the collected data as an argument to create or overwrite the package.json file with the new content.

  5. 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());
    });
  });
}
0.2.1

6 months ago

0.2.0

6 months ago

0.1.5

6 months ago

0.1.4

6 months ago

0.1.3

6 months ago

0.1.2

6 months ago

0.1.1

6 months ago

0.1.0

6 months ago