Creando CLI tools con Node, Oclif

y alguna cosilla más

El CLI más básico

Aplicación ejecutable en un paquete NPM

$ mkdir my-cli && cd $_ # "take my-cli" si usas Oh My Zsh
$ npm init -y

1. Crea el paquete NPM

Aplicación ejecutable en un paquete NPM

#!/usr/bin/env node

console.log('Hola');

2. Un archivo marcado como ejecutable por Node

Aplicación ejecutable en un paquete NPM

{
  "name": "my-cli",
  "main": "index.js",
  "bin": {
    "di-hola": "./index.js"
  }
}

3. Campo bin en el package.json con el nombre del comando y la ruta al ejecutable:

Aplicación ejecutable en un paquete NPM

3. También puede ser un string con la ruta al ejecutable. El nombre del comando es el nombre del paquete.

{
  "name": "my-cli",
  "main": "index.js",
  "bin": "./index.js"
}

Vamos a probarlo

1. Globalmente

# desde la carpeta del paquete (my-cli)
$ npm link

$ cd ~/Desktop
$ di-hola

Vamos a probarlo

¿Qué ha pasado?

/your-global-node/bin/di-hola -> 
/your-global-node/lib/node_modules/my-cli/index.js

La aplicación (di-hola) es un enlace simbólico que apunta al script ejecutable en la instalación global de nuestro paquete.

/your-global-node/lib/node_modules/my-cli -> 
/Users/projects/my-cli

La instalación de nuestro paquete dentro del node_modules global es a su vez un enlace simbólico que apunta al directorio original.

Vamos a probarlo

2. Dentro de otro paquete (como dependencia)

$ cd ~/another-package
$ npm link my-cli

Vamos a probarlo

¿Qué ha pasado?

$ cd ~/another-package
$ npm link my-cli

Lo mismo que ocurre globalmente pero dentro de la carpeta node_modules del paquete donde lo instalamos (enlazamos).

Vamos a probarlo

¿Qué ha pasado?

Ejecutándolo con npm scripts

Duda frecuente. Si lo tengo instalado globalmente y como dependencia... ¿?

{
  "name": "another-package",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "my-npm-script": "./node_modules/bin/di-hola"
  },
  "dependencies": {
    "my-cli": "^1.0.0"
  }
}
{
  "name": "another-package",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "my-npm-script": "di-hola"
  },
  "dependencies": {
    "my-cli": "^1.0.0"
  }
}

npm run añade node_modules/.bin al PATH usado por los scripts.

Usando parámetros

Comandos y opciones o flags

$ say hello
$ say bye

Comandos (CLI multi command)

Comandos y opciones o flags

$ say hello --name="Pepe"
$ say bye --uppercase

Comandos con opciones

Comandos y opciones o flags

$ sayhello
$ sayhello --name="Pepe"

Opciones o flags (CLI single command)

Lectura de parámetros con Node

process.argv

#!/usr/bin/env node

console.log(process.argv);
  • process es un objeto global de Node
  • argv (arguments vector)
  • El primer ítem del array es la ruta al ejecutable Node
  • El segundo la ruta al script ejecutado
  • A partir del tercero tenemos todos los parámetros adicionales

Lectura de parámetros con Node

➜ di-hola hola mundo
[ '/your-global-node/bin/node',
  '/your-global-node/bin/di-hola',
  'hola',
  'mundo' ]

Lectura de parámetros con Node

#!/usr/bin/env node

const [command] = process.argv.slice(2);

switch (command) {
  case 'hello':
    console.log('Hello');
    break;

  case 'bye':
    console.log('Bye');
    break;

  default:
    console.log('Unknown or missing command :(');
}

Lectura de parámetros con Node

$ say hello --name="Pepe"
$ say hello --name Pepe
$ say hello -n="Pepe"

La cosa se complica con los flags

Necesitamos parsearlos. ¿Alguna ayudita?

Librerías y frameworks

Parseadores de parámetros

Yargs

Parseadores de parámetros

Yargs

const yargs = require('yargs');

// comando --help muestra la descripcion de comandos y opciones
// comando --version muestra la version del package.json
// yargs API: https://github.com/yargs/yargs/blob/HEAD/docs/api.md
yargs
  .command({
    command: 'saluda',
    describe: 'Dice hola', 
    handler() {
      console.log(`Hola ${yargs.argv.nombre}`);
    }
  })
  .option('nombre', {
    alias: 'n',
    default: 'persona'
  })
  .argv;

Parseadores de parámetros

Yargs

$ di-hola saluda -n Pepe
Hola Pepe

$ di-hola saluda --nombre Pepe
Hola Pepe

$ di-hola saluda --nombre="Pepe García"
Hola Pepe García

$ di-hola saluda
Hola persona

Parseadores de parámetros

Commander

Un framework: Oclif

Oclif

Funcionalidades

  • Generador de CLIs (single o multi command)
  • Flags parser
  • Genera documentación
  • Utilidades (log, warn, error, exit)
  • Plugins (cli-ux proporciona spinners, un prompt básico, etc.)
  • JavaScript o TypeScript
  • Testing tools
  • Release (oclif-dev)

Manos a la obra. ¡Hagamos un generador!

Características de un generador

  • Recibe input del usuario
    • Prompt
    • Flags o parámetros
    • Archivos de configuración (incluyendo preferencias de usuario)
  • Genera un resultado usando templates
  • Puede ejecutar algún proceso adicional (npm install, bower install)
  • Si es muy amable, guarda tus preferencias para futuros usos

Especificaciones de nuestro generador

  • Crearemos un CLI single command con Oclif para hacer un generador de "localpens" (como un Codepen pero en nuestro equipo).
     
  • El localpen contendrá un archivo index.html, un javascript, un archivo de estilos a elegir (sass o css) y un package.json.
     
  • Podremos pasar como parámetros el título de la página y si queremos usar sass (css por defecto).
     
  • Los parámetros se podrán pasar como flags (deben tener precedencia), desde archivos de configuración o a través de un prompt.
     
  • Guardaremos el tipo de archivo de estilo preferido del usuario (sass o css) para futuros usos.
     
  • Crearemos nuestro localpen usando templates.
     
  • Por último instalaremos sus dependencias.

1. Single CLI con Oclif

➜ npx oclif single localpen

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

? npm package name localpen
? command bin name the CLI will export localpen
? description Crea codepens en mi equipo
...

2. Configurando flags

// src/index.js

LocalpenCommand.flags = {
  // add --version flag to show CLI version
  version: flags.version({char: 'v'}),
  // add --help flag to show CLI version
  help: flags.help({char: 'h'}),

  title: flags.string({
    char: 't',
    description: 'page title',
  }),

  sass: flags.boolean({
    char: 's',
    description: 'use sass for styles',
  }),
}

2. Configurando flags

// src/index.js

class LocalpenCommand extends Command {
  async run() {
    const {flags} = this.parse(LocalpenCommand)
    this.log(flags)
  }
}

Lo puedes ir probando con npm link

$ localpen --title "My local pen" --sass

3. Leyendo archivos de configuración

  • Nos evitan tener que pasar siempre los parámetros para configuraciones frecuentes o comunes a un proyecto.
  • Tienen efecto en directorios anidados.
  • Múltiples formatos: .*rc, tool.config.js, campo en package.json, etc.
  • Ejemplos: eslint, editorconfig, browserslist, postcss, babel, prettier...

Usaremos cosmiconfig

$ npm i cosmiconfig

3. Leyendo archivos de configuración

const {Command, flags} = require('@oclif/command')
const {cosmiconfig} = require('cosmiconfig')

class LocalpenCommand extends Command {
  async run() {
    const options = await this.getOptions()
    console.log('options', options)
  }

  async getOptions() {
    // read config found in files (.*rc, package.json, etc.)
    const explorer = cosmiconfig('localpen');
    const configInFiles = await explorer.search()
    const localConfig = configInFiles ? configInFiles.config : {}

    // override local config with flags
    const {flags} = this.parse(LocalpenCommand)
    return Object.assign(localConfig, flags)
  }
}

4. Añadiendo un prompt

  • Solicita parámetros al usuario directamente por consola usando una interfaz más amigable que los flags.
  • Admiten varios tipos de pregunta (input, list, choices, confirm, autocomplete, etc.)

Usaremos inquirer

$ npm i inquirer

4. Añadiendo un prompt

const {Command, flags} = require('@oclif/command')
const {cosmiconfig} = require('cosmiconfig')
const {prompt} = require('inquirer')

class LocalpenCommand extends Command {
  async run() {
    const options = await this.getOptions()
    console.log('options', options)
  }

  async getOptions() {
    // read config found in files (.*rc, package.json, etc.)
    const explorer = cosmiconfig('localpen');
    const configInFiles = await explorer.search()
    const localConfig = configInFiles ? configInFiles.config : {}

    // override local config with flags
    const {flags} = this.parse(LocalpenCommand)
    const params = Object.assign(localConfig, flags)

    return this.requestMissingParams(params)
  }
}

4. Añadiendo un prompt

class LocalpenCommand extends Command {
  // ...
  async requestMissingParams(params) {
    let answers = {}
    const questions = [
      {
        type: 'input',
        name: 'title',
        message: 'Page title',
      },
      {
        type: 'confirm',
        name: 'sass',
        message: 'Do you want to use sass?',
        default: false,
      },
    ]

    const notInParams = entry => !Object.keys(params).includes(entry.name)
    const missingParams = questions.filter(notInParams)

    if (missingParams.length > 0) {
      answers = await prompt(missingParams).catch(this.exit)
    }

    // mix the user answers with the params
    return Object.assign(params, answers)
  }
}

5. Guardando y usando preferencias

  • Nos permiten guardar los parámetros usados en la ejecución como preferencias para usos posteriores.
  • Candidatos: usernames, scopes npm, preferencias en cuanto a lenguajes, herramientas (eslint), etc. 

Usaremos Conf

$ npm i conf

5. Guardando preferencias

const {Command, flags} = require('@oclif/command')
const {cosmiconfig} = require('cosmiconfig')
const {prompt} = require('inquirer')
const Conf = require('conf')

class LocalpenCommand extends Command {
  constructor() {
    super(...arguments)
    this.name = 'localpen'
    this.userPrefs = new Conf({projectName: this.name})
  }

  async run() {
    const options = await this.getOptions()
    this.saveUserPreferences(options)
  }
  
  saveUserPreferences(options) {
    this.userPrefs.set(`${this.name}.sass`, options.sass)
  }  
}

5. Usando preferencias

  async requestMissingParams(params) {
    const userPrefs = this.userPrefs.get(this.name) || {}
    let answers = {}
    const questions = [
      {
        type: 'input',
        name: 'title',
        message: 'Page title',
      },
      {
        type: 'confirm',
        name: 'sass',
        message: 'Do you want to use sass?',
        default: userPrefs.sass,
      },
    ]

    //...
  }

En la siguiente ejecución, el prompt nos marcará por defecto la opción elegida previamente para sass.

6. Templates

  • Nos ofrecen variables o hooks que reemplazaremos por las opciones obtenidas a través del input (flags, config, prompt), tanto en texto, como en nombres de archivo.
  • Dependiendo de la librería de templating, podremos usar condiciones, bucles, etc.
  • Ejemplos: ejs, mustache, nunjucks, handlebars, etc.
$ npm i copy-template-dir

6. Templates

Descarga los templates (ya hechos) en la carpeta template  

$ npx degit kcmr/template-localpen template

6. Templates

const copyTemplateDir = require('copy-template-dir')
const {promisify} = require('util')
const path = require('path')
const copy = promisify(copyTemplateDir)

class LocalpenCommand extends Command {
  async run() {
    const options = await this.getOptions()
    this.saveUserPreferences(options)

    await this.createLocalpen(options)
  }
  
  async createLocalpen(options) {
    const srcDir = path.join(__dirname, '..', 'template')
    const destDir = process.cwd()

    const stylesExtension = options.sass ? 'scss' : 'css'
    const templateVars = {
      title: options.title,
      format: stylesExtension,
    }

    await copy(srcDir, destDir, templateVars)
  }  
}

7. Instalando dependencias

Usaremos execa (promise based)

$ npm i execa

7. Instalando dependencias

const execa = require('execa')

class LocalpenCommand extends Command {
  async run() {
    const options = await this.getOptions()
    this.saveUserPreferences(options)

    await this.createLocalpen(options)
    this.installDependencies()
  }
  
  installDependencies() {
    const subprocess = execa('npm', ['i'], {
      cwd: process.cwd(),
      stdio: 'inherit',
    })

    subprocess.on('close', () => {
      this.log('💥 Run "npm start" to lauch your localpen!')
    })
  }
}

Todo junto (repo)

Distribución

NPM se entiende con Github

  • A la hora de distribuir nuestro paquete podemos optar por publicarlo en el registro de NPM. Necesitaremos cuenta, login y hacer un npm publish.
  • Si lo tenemos alojado en Github podemos instalarlo directamente desde ahí.
$ npm i -g yourgithubuser/create-localpen
$ cd some-folder
$ localpen

Instalado globalmente:

NPM se entiende con Github

  • Si nuestro repo comienza por "create-", el comando npm init hará algo de magia usando npx y ejecutará el archivo binario.
  • Nos permite ejecutar siempre la última versión del generador a partir de Github.
# repo yourgithubusername/create-localpen
$ npm init yourgithubusername/localpen

Ejecutado sin instalación:

Gracias :)

Más recursos

Creando CLI tools (generadores) con Node y Oclif

By Kus Cámara

Creando CLI tools (generadores) con Node y Oclif

Ingredientes básicos de un CLI con Node. Librerías, frameworks y utilidades. Uso de Oclif para crear un generador. Distribución a través de Github y npm (npx).

  • 587