x-technology

XTechnology - Technology is beautiful, let's discover it together!

Mastering CLI with TypeScript

We like Node, Command Line Interfaces, JavaScript and TypeScript. We write and speak about technologies in meetups and conferences. However, the best knowledge sharing - is live sessions with theory and coding exercises. This is exactly what this workshop is about

In 3 hours we’ll write a complete CLI application. You will have a project in your Github repository, ready to use and evolve. You will know how it is designed, understand technologies and best practices it is following

How it Goes

We will start with theory - overview key concepts of building a Command Line Interface program. By making a tiny example in Node, we explore npm and package.json capabilities, compare different technologies. We build a TypeScript based pluggable CLI with oclif framework and design internal’s business logic. In the final part we polish program UX and output using various libraries.

Our goals are to learn awesome technologies, practice modern techniques, and of course, make your CLI application production ready!

Agenda

Prerequisites

Instructors

Alex Korzhikov & Pavlik Kiselev

Materials

repository-open-graph-template 1

Mastering CLI with TypeScript

Workshop Begins!

Introduction

Agenda

Node

Goals

Node

github

Who are we?

Alex Korzhikov

alex korzhikov photo

Software Engineer, Netherlands

My primary interest is self development and craftsmanship. I enjoy exploring technologies, coding open source and enterprise projects, teaching, speaking and writing about programming - JavaScript, Node.js, TypeScript, Go, Java, Docker, Kubernetes, JSON Schema, DevOps, Web Components, Algorithms 👋 ⚽️ 🧑‍💻 🎧

Pavlik Kiselev

Pavlik

Software Engineer, Netherlands

JavaScript developer with full-stack experience and frontend passion. He runs a small development agency codeville.agency and likes to talk about technologies they use: React, Remix and Serverless.

CLI in Node.js

A command-line interface or command language interpreter (CLI), is a means of interacting with a computer program where the user (or client) issues commands to the program in the form of lines of text (command lines). A program which handles the interface is called a command language interpreter or shell.

Shell is a program that takes commands from the keyboard and gives them to the operating system to perform

cat /etc/shells   # List of shells
cat /etc/passwd   # Default shell

© Wiki

¿Por qué?

Which CLI program

question

Why CLI?

Why Node?

➖?

Why TypeScript?

Principles Question

question

Which basic principles of designing a CLI program you might mention?

npx cowsay hello cow
 ___________
< hello cow >
 -----------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

Examples

NPM git

Principles

Top

Generators & Developer Experience

Workshop CLI

workshop cli

npm install --global cli-in-ts
workshop
# or workshop help
workshop hello
# and go to practice
workshop go

Hello World CLI in Node

package.json

{
  "name": "my-hello-world-cli",
  "version": "1.0.0",
  "description": "Hello CLI",
  "main": "server.js",
  "bin": "server.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "man" : "./man/doc.1"
}

Execution

#!/usr/bin/env node

question

What will be an output of running server.js?

// server.js
console.log(process.argv)
node server.js hello world

Windows Specifics

Try to run bash directly with Windows Subsystem for Linux (for Windows 10)

Bash on Ubuntu on Windows

const { spawn } = require('child_process');
const bat = spawn('cmd.exe', ['/c', '"my script.cmd"']);

How to run Node program in Windows?

%USERPROFILE%/AppData/Roaming/npm

When running npm install -g . in Windows, .cmd extension file is generated along by npm to enable .js file execution with Node

oclif run.cmd example

@echo off

node "%~dp0\run" %*

Practice - Hello World

Make the Hello World CLI in Node

mkdir my-hello-world-cli
cd my-hello-world-cli
npm init
# answer npm questions and check package.json content
echo "console.log('Hello CLI')" > server.js
# check if environment works
npm start
# use bin package.json property to point to server.js
# don't forget to add the shebang
# #!/usr/bin/env node
# in the top of the server.js file
# install cli globally
npm install --global .
# when execute the CLI in the terminal
my-hello-world-cli
# the result should be in the console
# Hello CLI

Practice - Parse arguments

Read package.json fields - name, version, and description

"name": "my-hello-world-cli",
"version": "1.0.0",
"description": "My First Node.js CLI",

Show help message when user doesn’t provide any flags

my-hello-world-cli

Package description
Package version

Usage:
--help    Help documentation
--version Installed package version

Show version message when user provides --version argument

my-hello-world-cli --version

my-hello-world-cli 1.0.0

spoiler alert

Hello World CLI in Node

TypeScript

JavaScript that scales. TypeScript is a typed superset of JavaScript that compiles to plain JavaScript. Any browser. Any host. Any OS. Open source.

ts

Anders Hejlsberg, 2012 @ Microsoft

Tools

Practice - Hello World with TypeScript

Migrate Hello World CLI to TypeScript

# install typescript globally
# or use npx instead
npm install --global typescript
# initialize typescript compiler project configuration file
tsc --init
# rename js file to ts
mv server.js server.ts
# @types/node
npm install --save-dev @types/node
# compile project to typescript
tsc
# install cli globally
npm install --global .
# try if cli works
my-hello-world-cli

Troubleshooting

Cannot redeclare block-scoped variable 'name'.ts(2451)

By default, TypeScript uses the DOM typings for the global execution environment and the name property exists on the global window scope.

There are two easy ways to avoid this problem:

Change require() to import ... from ....

To import from json module add the resolveJsonModule TypeScript compiler option.

{
  "compilerOptions": {
    "lib": [
      "es2015"
    ]
  }
}

spoiler alert

Hello World CLI in TypeScript

Make it Work with oclif

oclif

Heroku, SalesForce framework to build CLIs

Features

Command

Extend Command base class for application’s commands

import { Command } from '@oclif/command'

export class MyCommand extends Command {
  static description = 'description of this example command'

  async run() {
    console.log('running my command')
  }
}

Arguments

Arguments are declared on the command level, parsed by oclif and used for documentation generation

LOG_LEVEL=debug note
import { Command, flags } from '@oclif/command'

export default class MyCommand extends Command {
  static flags = {
    logLevel: flags.string({
      description: `Environment variable 'LOG_LEVEL'.\nIt CAN NOT be passed as a flag`,
      env: 'LOG_LEVEL',
    })
  }

  async run() {
    const { flags: { logLevel } } = this.parse(MyCommand)

    console.log(`running my command with logLevel ${logLevel}`)
  }
}

Practice - Configure oclif project

Create a new CLI project with oclif generator

npx oclif multi my-oclif-cli
cd my-oclif-cli
npm install -g .
my-oclif-cli hello

Note - Project Management as CLI

Educational Open Source Project to practice with JavaScript, TypeScript, Node, oclif, Git, Web Components, and Project Management

Practice - Make it Work

Make a command to send Hello World notification to slack

npm install @slack/webhook
npx oclif command slack

Example of input/output

my-oclif-cli slack "Hello from @username"
# the message "Hello from @username" appears in the slack channel

Configure your Slack

1. How to obtain the WebHook URL for our Slack Node-Edu Channel:

2. Put the Webhook URL to config/.slackrc file as SLACK_WEBHOOK_URL environment variable

export SLACK_WEBHOOK_URL=___WEBHOOK_GOES_HERE___
# or
export SLACK_WEBHOOK_URL=$(echo "aHR0cHM6Ly9ob29rcy5zbGFjay5jb20vc2VydmljZXMvVEwwMzg2V1BOL0JRMzRWREhQVy9DTjg3d2NVYlE4YTkyMmhaZjBaeEgwMVM=" | base64 --decode)
# or
export SLACK_WEBHOOK_URL=$(workshop slack)

3. Import .slackrc to your shell with source

source config/.slackrc

Install NPM dependencies

npm i @slack/webhook

Write the command

1. Require an IncomingWebhook class from the @slack/webhook

import { IncomingWebhook } from '@slack/webhook'

2. Set a description for your command

static description = 'Send a message to a channel in Slack'

3. The text should be provided as an argument. So, lets define an argument

static args = [
  {
    name: 'text',
    required: true
  }
]

4. Add a definition of the flag to flags section. You may still keep “help” flag since it’s quite useful usually

static flags = {
  help: flags.help({
    char: 'h'
  }),
  slackWebhookUrl: flags.string({
    env: 'SLACK_WEBHOOK_URL',
    required: true
  })
}

5. In the very beginning of the run function lets get our flags and args from the input with the following line

const { flags, args } = this.parse(Slack)

6. Next lets create a new instance of IncomingWebhook with a slackWebhookUrl flag

const webhook = new IncomingWebhook(flags.slackWebhookUrl)

7. Call the “send” method with an object containing “text” property with your text. Please bear in mind that this is an async function

await webhook.send({ text: args.text })

spoiler alert

Send Slack message code

my-oclif-cli slack "Hello World!"

Make it Shine

Effects

Beautify Input and Output

listr - terminal task list listr

@oclif/cli-ux

oclif utilities for input & output

import cli from 'cli-ux'
cli.prompt('What is your password?', {type: 'mask'})

Features

Practice - List Github Issues

Make a command to list Github tasks

github

Use @oclif/cli-ux or any other tools to

npx oclif command github:issues
my-oclif-cli github:issues
Getting a list of issues... done
Number Title                                      Assignee      State Link
66     workshop CLI                               korzio        open  https://github.com/korzio/note/issues/66
65     fix: Changed the formatting of exercises   null          open  https://github.com/korzio/note/pull/65
64     Workshop CLI in TS on Saturday 9am 3 hours korzio        open  https://github.com/korzio/note/issues/64
63     Add test section and example to workshop   paulcodiny    open  https://github.com/korzio/note/issues/63
...

Configure an access

1. Create a Personal token

2. Add it to the config file .githubrc to variable GITHUB_PERSONAL_TOKEN

export GITHUB_PERSONAL_TOKEN=___TOKEN_GOES_HERE___

3. Export this variable to the current shell with source command

source config/.githubrc

4. Use the auth key with @octokit/rest

5. Get the list of Github issues

Install NPM dependencies

npm i cli-ux chalk @octokit/rest

Write the code

1. Import cli from cli-ux to use advanced formatting

import cli from 'cli-ux'

2. Import chalk from chalk to use colors

import chalk from 'chalk'

3. Require the Octokit. This library is imported in a specific way

import Octokit = require('@octokit/rest')

4. Set a description for your command

static description = 'Get a list of issues'

5. Add arguments: one for an owner and for a repository

static args = [
  {
    name: 'owner',
    required: false,
    description: 'An owner of a repository',
    default: 'korzio',
  },
  {
    name: 'repo',
    required: false,
    description: 'A repository',
    default: 'note',
  },
]

6. Add a GITHUB_PERSONAL_TOKEN flag to flags definition so oclif will put the environment variable to a flag

static flags = {
  help: flags.help({
    char: 'h'
  }),
  githubPersonalToken: flags.string({
    description: `Environment variable GITHUB_PERSONAL_TOKEN`,
    env: 'GITHUB_PERSONAL_TOKEN',
    required: true
  })
}

7. Use cli.action.start to show the loader with some useful information what is happening

cli.action.start('Getting the list of the issues')

8. Create a new instance of Octokit with an object argument containing the “auth” property with the auth key created in the previous section

const octokit = new Octokit({
  auth: flags.githubPersonalToken
})

9. Call the “issues.listForRepo” method with an object argument containing “owner” and “repo” keys. You can pass “korzio” as an owner and “note” as a repository. Documentation of the method https://octokit.github.io/rest.js/#octokit-routes-issues-list-for-repo. The result of this method is an object containing “data” property

const { data: issues } = await octokit.issues.listForRepo({
  owner: 'korzio',
  repo: 'note',
})

10. Stop the loader with cli.action.stop

cli.action.stop()

11. Show tha table with the “data” as the first argument and the object with table description as the second. You can use columns “number”, “title”, “assignee” with a getter to get deep property, “state” with a getter to color the resulting state, “html_url” with a different header

cli.table(issues, {
  number: {},
  title: {},
  assignee: {
    get: row => row.assignee ? row.assignee.login : null,
  },
  state: {
    get: row => row.state === 'open' ? chalk.green('open') : chalk.red('closed'),
  },
  html_url: {
    header: 'Link'
  },
})

spoiler alert

Get a list of issues code

## Practice - Assign Yourself on an Issue

Develop a command to change an assignee

Use @oclif/cli-ux - prompt() functionality.

my-oclif-cli github:assignee
Do you want to start working on an issue? (Y/n) [y]: y
Which issue you want to pick up? Please provide the ID: 62
What is your GitHub login?: paulcodiny

1. Ask whether the user wants to start working on an issue. Use capital letter to communicate the default choice even though oclif helps with this

const startWorking = await cli.prompt('Do you want to start working on an issue? (y/N)', {
  required: false,
  default: 'y'
})

2. If the choice is “y” then show additional promts

if (['y', 'yes'].includes(startWorking.toLowerCase())) {
  const issueNumber = await cli.prompt('Which issue you want to pick up? Please provide the Number')
  const assignee = await cli.prompt('What is your GitHub login?')

  // ...
}

3. After the CLI script gathers all required inputs we can perform an update

await octokit.issues.update({
  owner: args.owner,
  repo: args.repo,
  issue_number: issueNumber,
  assignees: [assignee]
})

4. Do not forget to communicate back the success message.

this.log(`Assignee of the issue #${issueNumber} has been successfully changed to "${assignee}"!`)

spoiler alert

Change an assignee code

# oclif in Depth

oclif Abstractions

Configuration in package.json with oclif property

"oclif": {
  "commands": "./lib/commands",
  "bin": "my-oclif-cli",
  "plugins": [
    "@oclif/plugin-help"
  ],
  "hooks": {
    "commit": "./lib/hooks/commit/commit"
  }
},

oclif CLI manifest command generates configuration declaration for publish and load details purposes

List of useful plugins made by oclif, like plugin-help, plugin-autocomplete or plugin-plugins

Hooks

Extending commands like lifecycle callbacks

Custom hooks can be called programmatically
await this.config.runHook('custom', { arguments })

Practice - Notify Slack on Issues Update

1. Generate a hook to notify slack on issues update

oclif hook notify --event=notify
cat src/hooks/notify/notify.ts
import {Hook} from '@oclif/config'

const hook: Hook<'notify'> = async function (opts) {
  process.stdout.write(`example hook running ${opts.id}\n`)
}

export default hook

2. Let’s modify github:assignee command so it sends a notification to slack

my-oclif-cli github:assignee
## after the issue start command is finished
## the notify hook sends slack message

3. We’ll need to add a environment variable input flag in the same way we did with slack command itself

slackWebhookUrl: flags.string({
  env: 'SLACK_WEBHOOK_URL',
  required: true
})

4. Parse the flag inside the github:assignee command

const {slackWebhookUrl: url} = flags

5. To execute the hook call the runHook() method on a command’s context with appropriate arguments

this.config.runHook('notify', {url, text})

Now oclif should be able to find existing notify functionality

spoiler alert

Notify slack on assignee change code

Practice - Add Tests to github:issues Command

Add a test to check the output of the github:issues

npm test

> my-oclif@1.0.0 test /Users/paulcodiny/Projects/clits/experiments/my-oclif-cli
> nyc --extension .ts mocha --forbid-only "test/commands/github/issues.test.ts"



  github:issues
...

Getting a list of issues... done
Number Title                              Assignee State Link
33272  Google feedback on TypeScript 3.5  evmar    open  /microsoft/TypeScript/issues/33272
    ✓ should format the table (3305ms)


  1 passing (3s)
...

Update the package.json

Basically, for now we can focus only on one test and for it let’s update a bit the package.json

{
  "scripts": {
    "test": "nyc --extension .ts mocha --forbid-only \"test/commands/github/issues.test.ts\"",
  }
}

Write the test

1. Import required dependencies

import {expect, test} from '@oclif/test'

2. Create a describe section for the command

describe('github:issues', () => {
  // ...
})

3. Create a test for the issues command

test
  // ...

4. Mock the response from github with the help of nock package. Please note, we don’t have data property in the response. If’s automatically created for us by nock

test
  .nock('https://api.github.com', api => api
    .get('/repos/korzio/note/issues')
    .reply(200, [
      {
        number: '33272',
        title: 'Google feedback on TypeScript 3.5 ',
        assignee: {
          login: 'evmar'
        },
        state: 'open',
        html_url: 'https://github.com/microsoft/TypeScript/issues/33272'
      }
    ])
  )

5. Capture the stdout to the variable. Pass { print: true } to simplify debugging. It’s not required for your build pipelines (CI/CD) and can be even harmful for your logs

test
  // nock...
  .stdout({ print: true })

6. Next let’s run the command. We don’t need to provide arguments - for now it will be korzio/note

test
  // nock...
  // stdout...
  .command(['github:issues'])

7. After all it’s time for the expectations - oclif uses chai as the expectation library underneath

test
  // .nock...
  // .stdout...
  // .command...
  .it('should show the issues from github', ctx => {
     expect(ctx.stdout)
       .to.contain('33272')
       .and.to.contain('Google feedback on TypeScript 3.5')
       .and.to.contain('evmar')
       .and.to.contain('open')
       .and.to.contain('https://github.com/microsoft/TypeScript/issues/33272')
   })

spoiler alert

github:issues test

Practice - Commands VS Plugins

1. Move Github commands and logic into a new plugin

oclif plugin manage-github

     _-----_
    |       |    ╭──────────────────────────╮
    |--(o)--|    │   Time to build a oclif  │
   `---------´   │  plugin! Version: 1.13.6 │
    ( _´U`_ )    ╰──────────────────────────╯
    /___A___\   /
     |  ~  |
   __'.___.'__
 ´   `  |° ´ Y `

? npm package name manage-github
? description
? author Alex Korzhikov @korzio
? version 0.0.0
? license MIT
? Select a package manager npm
? TypeScript Yes
? Use tslint (linter for TypeScript) Yes
? Use mocha (testing framework) Yes

2. Move all the github related project code into a generated plugin

3. Update CLI core to be able installing other plugins

Please note - if in the first oclif exercise the CLI was generated as multi, package.json should already contain this dependency

npm i @oclif/plugin-plugins

4. Test functionality locally by installing the plugin into a core

my-oclif-cli plugins:link ./manage-github
my-oclif-cli github:assignee
# should still work

spoiler alert

Split project code into plugins

Final

Summary

Node

github

Feedback

Please share your feedback on Mastering CLI in TypeScript workshop

workshop feedback

Docs

Thank you!

Alex Korzhikov

Pavlik Kiselev