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
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!
Alex Korzhikov & Pavlik Kiselev
Introduction
package.json
TypeScript
oclif
oclif
projectslack
hello worldoclif
in Depth
Understand Basic CLI Concepts
Practice coding JavaScript
& TypeScript
CLI programs in Node
Overview popular npm
tools, libraries & frameworks for constructing CLIs
Make an oclif
CLI application to manipulate Github
repository and send notifications to slack
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 👋 ⚽️ 🧑💻 🎧
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.
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
CLI
?JavaScript
?Node
?TypeScript
?JavaScript
JavaScript
tools are the best for JavaScript
& FrontEndnpm
Node
need to be installed?!CLI
program you might mention?npx cowsay hello cow
___________
< hello cow >
-----------
\ ^__^
\ (oo)\_______
(__)\ )\/\
||----w |
|| ||
help
version
stdout
, errors for stderr
process.exit(code)
$?
in shell
git
npm
npm install --global cli-in-ts
workshop
# or workshop help
workshop hello
# and go to practice
workshop go
{
"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"
}
main
- exportsbin
- make an executable symlink
inside PATH
, ./node_modules/.bin/
url
- npm bugs
feedback on a package 🤗shebang
specifies an interpeter in *nix
systems (also in Windows
)#!/usr/bin/env node
process.argv
contains arguments which a program is called withserver.js
?// server.js
console.log(process.argv)
node server.js hello world
Windows Console
- a program to run applications with text-based interfacecmd.exe
or Command Prompt
- venerable Windows Command ProcessorPowerShell
an extended scripting language and a framework, providing powerful command-line tools for most Windows capabilities and APIsbash
directly with Windows Subsystem for Linux (for Windows 10)Learn About Windows Console & Windows Subsystem For Linux (WSL) - Microsoft
HOME
vs HOMEPATH
)path
build-in Node
module to construct locationsconst { spawn } = require('child_process');
const bat = spawn('cmd.exe', ['/c', '"my script.cmd"']);
Unix
shell commands written in Node
%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" %*
%*
- will return the remainder of the command line starting at the first command line argument (in Windows NT 4, %* also includes all leading spaces)%~dn
- will return the drive letter of %n (n can range from 0 to 9) if %n is a valid path or file name (no UNC)%~pn
- will return the directory of %n if %n is a valid path or file name (no UNC)
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
package.json
fields - name
, version
, and description
"name": "my-hello-world-cli",
"version": "1.0.0",
"description": "My First Node.js CLI",
my-hello-world-cli
Package description
Package version
Usage:
--help Help documentation
--version Installed package version
--version
argumentmy-hello-world-cli --version
my-hello-world-cli 1.0.0
JavaScript that scales. TypeScript is a typed superset of JavaScript that compiles to plain JavaScript. Any browser. Any host. Any OS. Open source.
Anders Hejlsberg, 2012 @ Microsoft
typescript, tsc
- compile to JavaScript
@types
- types definitions, @types/nodets-node
- on-the-fly TypeScript
execution for Node
# 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
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:
modules
instead of commonjs
Change require()
to import ... from ...
.
To import from json
module add the resolveJsonModule
TypeScript compiler option.
DOM
typings. Add an explicitly empty lib
array property in the tsconfig.json
to not include DOM
{
"compilerOptions": {
"lib": [
"es2015"
]
}
}
TypeScript
(can be JavaScript
)yeoman
)
Command
base class for application’s commandsimport { Command } from '@oclif/command'
export class MyCommand extends Command {
static description = 'description of this example command'
async run() {
console.log('running my command')
}
}
oclif
and used for documentation generationyargs
, nops
, or minimist
as alternative libraries
npm i --verbose
git log --abbrev-commit --pretty=oneline -n 50
npm install yargs
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}`)
}
}
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
slack
npm install @slack/webhook
npx oclif command slack
my-oclif-cli slack "Hello from @username"
# the message "Hello from @username" appears in the slack channel
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
npm i @slack/webhook
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 })
my-oclif-cli slack "Hello World!"
listr
- terminal task list
progress
show statusfiglet
ASCII outputchalk, colors
for colorsclui
output tables, status, chartscli-table
print table dataclear
clear terminaldebug
wrap console logimport cli from 'cli-ux'
cli.prompt('What is your password?', {type: 'mask'})
url(), open()
for urlsaction()
immersive logstable(), tree()
to print lists and structuresUse @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
...
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
npm i cli-ux chalk @octokit/rest
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'
},
})
## Practice - Assign Yourself on an Issue
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}"!`)
# oclif
in Depth
oclif
Abstractionspackage.json
with oclif
property"oclif": {
"commands": "./lib/commands",
"bin": "my-oclif-cli",
"plugins": [
"@oclif/plugin-help"
],
"hooks": {
"commit": "./lib/hooks/commit/commit"
}
},
manifest
command generates configuration declaration for publish and load details purposesoclif
, like plugin-help
, plugin-autocomplete
or plugin-plugins
init
- before any command when CLI is initialied,prerun
- after init
hook, but also before the command,command_not_found
- if a command is not found before the errorpreupdate
update
plugins:preinstall
await this.config.runHook('custom', { arguments })
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
github:issues
Commandgithub: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)
...
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\"",
}
}
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')
})
Command
is a granular functionalityPlugin
is a pack of commands
, hooks
or other things grouped by semantic reasons1.
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
Understand Basic CLI Concepts
Overviewed different npm
packages for developing a CLI
Practice with CLI in Node
with TypeScript
and popular frameworks & libraries
Make an oclif
CLI application to manipulate Github
repository and send Hello World notifications to slack
workshop feedback
Building Great CLI Experiences in Node - Jeff Dickey, Heroku
Building an enterprise-grade CLI with oclif by Thomas Dvornik
Build a JavaScript Command Line Interface (CLI) with Node.js — SitePoint