Create and Publish Your First CLI Using Typescript

galelmalah

Gal Elmalah

Posted on February 8, 2021

Create and Publish Your First CLI Using Typescript

Following my previous blog post


I wrote a quick guide on how to write and publish a CLI.

What's in it for you?

  1. Write a cool as f*** CLI tool.
  2. Learn how to set up a project using Typescript.
  3. Publish your new shiny CLI to npm.

setup

We will use Scaffolder to generate all the boilerplate we need for our shiny CLI.

npx scaffolder-cli interactive --from-github https://github.com/galElmalah/ts-cli-scaffolder.git --template cli
Enter fullscreen mode Exit fullscreen mode

Scaffolder makes creating and sharing boilerplate code a breeze, Check it out!

Once npm has finished installing all of our dependencies, we should have a clean, greenfield project.


Let's have a quick look at the package.json file.

First of all, as you can see we got a postfix to our name field, I added this to prevent naming conflicts with existing packages 😄

Second, we got a bin field.
bin field tells npm that this package has an executable that should be invoked using the coolGroup command.

"bin" : {
  "coolGroup" : "./dist/cli.js"
}
Enter fullscreen mode Exit fullscreen mode

Finally, we have commander as a dependency. We are going to use it to register commands for our cli to act on.

In a gist commander makes creating CLI's a breeze

Now Let's quickly go over the tsconfig.json file.

{
  "compilerOptions": {
    "module": "commonJs", // Module code generation
    "target": "es6", // Target a specific ECMAScript version
    "outDir": "dist/", // The TSC compiler will output our files to the ./dist folder
    "lib": ["es6"] // Specify library files to be included in the compilation step
  },
  "files": ["src/cli.ts"], // Mark cli.ts as our entry point
  "exclude": ["node_modules"]
}
Enter fullscreen mode Exit fullscreen mode

We mentioned ./dist/cli.js in the bin field. We can do that because we tell typescript to compile our code into a dist folder.

If you want to learn more about Typescript or tsconfig.json, I recommend this free book.

We are finally done going over our boilerplate. Let's get down to business.

We are going to write a simple CLI that does the following:

  1. Go over all the files in a directory and get their extension.
  2. Create a folder for each type of file extension.
  3. Move all the files to their matching folders.

0.5. Some imports for later

import { readdirSync, existsSync, statSync, mkdirSync, renameSync } from 'fs';
import { join } from 'path';
Enter fullscreen mode Exit fullscreen mode

1. Go over all the files in a directory and get their extension.

// `getPath` is a little helper that will make more sense when we will look at the whole file.
const getPath = (...paths) => join(sourcePath, ...paths);
const toFileExtension = (fromFileName: string) => fromFileName.split('.').pop();
const isFile = (aFile: string) => statSync(getPath(aFile)).isFile();

const files = readdirSync(sourcePath).filter(isFile);

const getWorkingDirectoryFileExtensions = (): string[] =>
  Array.from(new Set(files.map(toFileExtension)));
Enter fullscreen mode Exit fullscreen mode

2. Create a folder for each type of file extension.

If the folder already exists, then skip its creation to avoid errors.

const createDirectory = (aFileExtension: string) =>
  mkdirSync(getPath(aFileExtension));
const shouldCreateFolder = (aFileExtension: string) =>
  !existsSync(getPath(aFileExtension));

getWorkingDirectoryFileExtensions()
  .filter(shouldCreateFolder)
  .forEach(createDirectory);
Enter fullscreen mode Exit fullscreen mode

3. Move all the files to their matching folders.

const moveToFileExtensionFolder = (aFile) =>
  renameSync(getPath(aFile), getPath(toFileExtension(aFile), aFile));

files.forEach(moveToFileExtensionFolder);
Enter fullscreen mode Exit fullscreen mode

Putting it all together

We are going to put all of this logic inside a file named groupFilesByExtensions.ts

import { readdirSync, existsSync, statSync, mkdirSync, renameSync } from 'fs';
import { join } from 'path';

export const groupFilesByExtensions = (sourcePath: string) => {
  const getPath = (...paths: string[]) => join(sourcePath, ...paths);
  const toFileExtension = (fromFileName: string) =>
    fromFileName.split('.').pop();
  const isFile = (aFile: string) => statSync(getPath(aFile)).isFile();

  const files = readdirSync(sourcePath).filter(isFile);

  const getWorkingDirectoryFileExtensions = () =>
    Array.from(new Set(files.map(toFileExtension)));

  const createDirectory = (aFileExtension) =>
    mkdirSync(getPath(aFileExtension));
  const shouldCreateFolder = (aFileExtension) =>
    !existsSync(getPath(aFileExtension));

  getWorkingDirectoryFileExtensions()
    .filter(shouldCreateFolder)
    .forEach(createDirectory);

  const moveToFileExtensionFolder = (aFile: string) =>
    renameSync(getPath(aFile), getPath(toFileExtension(aFile), aFile));

  files.forEach(moveToFileExtensionFolder);
};
Enter fullscreen mode Exit fullscreen mode

We got all of our logic in working condition. Now, let's wire this thing up.

What will be a reasonable workflow for this CLI? Let's write it up as a user story.

1. As a user, I want to type coolGroup in my cli and have all files in my current working directory grouped.

By importing our groupFilesByExtensions function into cli.ts file.

We add a shebang(#!/usr/bin/env node) to specify the script interpreter that's used to execute our code.

#!/usr/bin/env node

import { groupFilesByExtensions } from './groupFilesByExtensions';

// process.cwd() give us back the current working directory
groupFilesByExtensions(process.cwd());
Enter fullscreen mode Exit fullscreen mode

Let's introduce another requirement and see we can adjust to it.

2. As a user, I to be able to specify the folder coolGroup will work on.

For example coolGroup --entry-point ./group/this/folder

Change the cli.ts file to accommodate this change

#!/usr/bin/env node
import * as commander from 'commander';
import { groupFilesByExtensions } from './groupFilesByExtensions';

commander
  .option(
    '--entry-point [value]',
    'Relative path to a folder you want to group.'
  )
  .action((command) => {
    /*
    commander parses the input for us.
    The options we specify then get exposed via the `command` argument - command.<our-option>
    */
    const groupFolderPath = command.entryPoint
      ? join(process.cwd(), command.entryPoint)
      : process.cwd();
    groupFilesByExtensions(groupFolderPath);
  })
  .parse(process.argv);
Enter fullscreen mode Exit fullscreen mode

Now our users can specify a path to the folder they want to group.

As a bonus, we get a nice help section out of the box!

run npm run build and then node ./dist/cli.js to see it in action locally (or use npm link)

help output


Share it with the world!

We got a cool working CLI but it only exists on our local machine.

Let's share this brilliant creation with the world by publishing it to npm.

Before moving to the next section, if you don't have an npm user follow this guide to create one and set up the credentials.

To publish our package all we need is to run npm publish and you should be good to go!

For a more polished publish flow check np out.

If everything went well you should see something like this.

publish result


check it out by running npx <your-module-name-here> inside whatever folder you like.

the cli in action

woohoo, we are all done.

party
Check out my other blog posts on dev.to

💖 💪 🙅 🚩
galelmalah
Gal Elmalah

Posted on February 8, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related