Following my previous blog post 5 Cli Tools That Will Increase Your Dev Velocity And Code Quality 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.

1
npx scaffolder-cli interactive --from-github https://github.com/galElmalah/ts-cli-scaffolder.git --template cli

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.

1
2
3
"bin" : {
  "coolGroup" : "./dist/cli.js"
}

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "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"]
}

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

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

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

1
2
3
4
5
6
7
8
9
// `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)));

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

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

1
2
3
4
5
6
7
8
const createDirectory = (aFileExtension: string) =>
  mkdirSync(getPath(aFileExtension));
const shouldCreateFolder = (aFileExtension: string) =>
  !existsSync(getPath(aFileExtension));

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

3. Move all the files to their matching folders.

1
2
3
4
const moveToFileExtensionFolder = (aFile) =>
  renameSync(getPath(aFile), getPath(toFileExtension(aFile), aFile));

files.forEach(moveToFileExtensionFolder);

Putting it all together

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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);
};

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.

1
2
3
4
5
6
#!/usr/bin/env node

import { groupFilesByExtensions } from './groupFilesByExtensions';

// process.cwd() give us back the current working directory
groupFilesByExtensions(process.cwd());

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#!/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);

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