Como criar uma CLI com Node.js

March 19, 2019
Escrito por

Como criar uma CLI com Node.js

As interfaces de linha de comando (CLIs) integradas ao Node.js permitem automatizar tarefas repetitivas e, ao mesmo tempo, aproveitar o vasto ecossistema Node.js. Graças aos gerenciadores de pacotes como npm e yarn, podem ser facilmente distribuídos e consumidos em várias plataformas. Nesta publicação, veremos por que você pode querer uma CLI, como usar o Node.js para ela, alguns pacotes úteis e como você pode distribuir sua nova CLI.

Por que criar CLIs com Node.js

Uma das razões pelas quais o Node.js ficou tão popular é o rico ecossistema de pacotes com mais de 900 mil pacotes no npm registry. Ao criar CLIs no Node.js, é possível acessar esse ecossistema, incluindo sua grande quantidade de pacotes focados em CLI. Entre outros:

  • inquirerenquirer ou prompts para solicitações de entrada complexas
  • email-prompt para solicitações de entrada de e-mail convenientes
  • chalk ou kleur para saída colorida
  • ora para ter lindos controles giratórios
  • boxen para caixas de desenho ao redor da saída
  • stmux para um tmux como UI
  • listr para listas de progresso
  • ink para criar CLIs com React
  • meow ou arg para análise de argumento básico
  • commander e yargs para analisar argumentos complexos e oferecer suporte a subcomandos
  • oclif, uma estrutura para a construção de CLIs extensíveis por Heroku (gluegun como alternativa)

Além disso, há muitas maneiras convenientes de consumir CLIs publicadas para npm de yarn e de npm. Use como exemplo create-flex-plugin, uma CLI que você pode usar para iniciar um plugin para o Twilio Flex. É possível instalá-lo como um comando global:

# Using npm:
npm install -g create-flex-plugin
# Using yarn:
yarn global add create-flex-plugin
# Afterwards you will be able to consume it:
create-flex-plugin

Ou como dependências específicas do projeto:

# Using npm:
npm install create-flex-plugin --save-dev
# Using yarn:
yarn add create-flex-plugin --dev
# Afterwards the command will be in
./node_modules/.bin/create-flex-plugin
# Or via npx using npm:
npx create-flex-plugin
# And via yarn:
yarn create-flex-plugin

Na verdade npx oferece suporte à execução de CLIs mesmo quando ainda não estão instalados. Basta executar npx create-flex-plugin e ele o baixará em um cache se não conseguir encontrar uma versão instalada local ou globalmente.

Por último, desde a versão 6.1 do npm, o npm init e yarn oferecem suporte a uma forma de iniciar projetos usando CLIs que são chamadas de create-*. Como exemplo, para nosso create-flex-plugin, só precisamos definir:

# Using Node.js
npm init flex-plugin
# Using Yarn:
yarn create flex-plugin

Agora que já vimos por que criar uma CLI usando Node.js, vamos colocar a mão na massa. Usaremos o npm neste tutorial, mas há comandos equivalentes para a maioria das coisas no yarn. Veja se o Node.js e npm estão instalados no sistema.

Neste tutorial, criaremos uma CLI que inicializa novos projetos de acordo com suas preferências executando npm init @your-username/project.

Inicie um novo projeto Node.js ao executar:

mkdir create-project && cd create-project
npm init --yes

Depois, crie um diretório chamado src/ na raiz do projeto e coloque um arquivo chamado cli.js com o seguinte código:

export function cli(args) {
 console.log(args);
}

Mais tarde, nessa mesma parte, analisaremos a lógica e, em seguida, acionaremos a lógica de negócios real. Em seguida, precisamos criar o ponto de entrada da CLI. Crie um novo diretório bin/ na raiz do projeto e crie um novo arquivo dentro chamado create-project. Insira as seguintes linhas de código:

#!/usr/bin/env node

require = require('esm')(module /*, options*/);
require('../src/cli').cli(process.argv);

Há algumas questões neste pequeno trecho. Primeiro, precisamos de um módulo chamado esm que permita usar import nos outros arquivos. Não está diretamente relacionado à criação de CLIs, mas usaremos os módulos ES neste tutorial, e o pacote esm permite isso sem a necessidade de transpor versões Node.js sem o suporte. Depois, precisaremos do arquivo cli.js e chamaremos a função cli exposta com process.argv, que é um array de todos os argumentos passados para esse script a partir da linha de comando. 

Antes de testar o script, precisaremos instalar a dependência esm ao executar:

npm install esm

Também precisamos informar ao gerenciador de pacotes que estamos expondo um script de CLI. É preciso adicionar a entrada apropriada no package.json. Além disso, atualize as propriedades descriptionnamekeyword e main:

{
 "name": "@your_npm_username/create-project",
 "version": "1.0.0",
 "description": "A CLI to bootstrap my new projects",
 "main": "src/index.js",
 "bin": {
   "@your_npm_username/create-project": "bin/create-project",
   "create-project": "bin/create-project"
 },
 "publishConfig": {
   "access": "public"
 },
 "scripts": {
   "test": "echo \"Error: no test specified\" && exit 1"
 },
 "keywords": [
   "cli",
   "create-project"
 ],
 "author": "YOUR_AUTHOR",
 "license": "MIT",
 "dependencies": {
   "esm": "^3.2.18"
 }
}

Se observar a chave bin, estamos passando um objeto com dois pares de chave/valor. Eles definem os comandos da CLI instalados pelo gerenciador de pacotes. Neste caso, registraremos o mesmo script para dois comandos. Com o uso do nosso próprio escopo do npm, usando nosso nome de usuário e uma vez como o comando genérico create-project por questões práticas.

Depois disso, podemos testar o script. A maneira mais fácil é usar o comando npm link. Execute no terminal dentro do projeto:

npm link

Dessa forma, um link simbólico é instalado para o projeto atual, de modo que não há necessidade de nova execução quando atualizarmos nosso código. Depois de executar o  npm link, os comandos CLI devem estar disponíveis. Tente executar:

create-project

A saída deve ser parecida com esta:

 '/usr/local/Cellar/node/11.6.0/bin/node'

Observe que ambos os caminhos serão diferentes para você, dependendo de onde está o seu projeto e onde o Node.js está instalado. Esta matriz será mais longa com cada argumento adicionado. Tente executar:

create-project --yes

A saída deve mostrar o novo argumento:

 '/usr/local/Cellar/node/11.6.0/bin/node'

Análise de argumentos e processamento de entrada

Agora estamos prontos para analisar os argumentos que estão sendo transferidos para o script e podemos começar a entender eles. A CLI oferece suporte a um argumento e algumas opções:

  • [template]: oferecemos suporte a diferentes modelos prontos para uso. Se não for aprovado, solicitarão ao usuário que selecione um template (modelo)
  • --git: executa git init para instanciar um novo projeto git
  • --install: instala automaticamente todas as dependências do projeto
  • --yes: preenche todos os prompts e utiliza opções padrão

Para este projeto, usamos inquirer para solicitar valores faltantes e a biblioteca arg para analisar nossos argumentos de CLI. Instale as dependências faltantes ao executar:

npm install inquirer arg

Primeiro, vamos escrever a lógica que analisa os argumentos em um objeto options com a qual podemos trabalhar. Adicione o seguinte código a cli.js:

import arg from 'arg';

function parseArgumentsIntoOptions(rawArgs) {
 const args = arg(
   {
     '--git': Boolean,
     '--yes': Boolean,
     '--install': Boolean,
     '-g': '--git',
     '-y': '--yes',
     '-i': '--install',
   },
   {
     argv: rawArgs.slice(2),
   }
 );
 return {
   skipPrompts: args['--yes'] || false,
   git: args['--git'] || false,
   template: args._[0],
   runInstall: args['--install'] || false,
 };
}

export function cli(args) {
 let options = parseArgumentsIntoOptions(args);
 console.log(options);
}

Tente executar create-project --yes e possível visualizar skipPrompt se tornar true ou tente transferir outro argumento, como create-project cli e a propriedade template deve ser definida.

Agora que podemos analisar os argumentos da CLI, precisamos adicionar a funcionalidade para solicitar as informações faltantes, bem como ignorar o prompt e recorrer aos argumentos padrão se o sinalizador --yes for transferido. Adicione o seguinte código ao arquivo cli.js:

import arg from 'arg';
import inquirer from 'inquirer';

function parseArgumentsIntoOptions(rawArgs) {
// ...
}

async function promptForMissingOptions(options) {
 const defaultTemplate = 'JavaScript';
 if (options.skipPrompts) {
   return {
     ...options,
     template: options.template || defaultTemplate,
   };
 }

 const questions = [];
 if (!options.template) {
   questions.push({
     type: 'list',
     name: 'template',
     message: 'Please choose which project template to use',
     choices: ['JavaScript', 'TypeScript'],
     default: defaultTemplate,
   });
 }

 if (!options.git) {
   questions.push({
     type: 'confirm',
     name: 'git',
     message: 'Initialize a git repository?',
     default: false,
   });
 }

 const answers = await inquirer.prompt(questions);
 return {
   ...options,
   template: options.template || answers.template,
   git: options.git || answers.git,
 };
}

export async function cli(args) {
 let options = parseArgumentsIntoOptions(args);
 options = await promptForMissingOptions(options);
 console.log(options);
}

Salve o arquivo e execute o create-project, e você deve receber uma solicitação de seleção de template (modelo):

Tela do terminal com o resultado do comando executado.

E depois será perguntado se deseja inicializar o git. Depois de selecionar ambos, verá uma saída como esta impressa:

{ skipPrompts: false,
  git: false,
  template: 'JavaScript',
  runInstall: false }

Tente executar o mesmo comando com -y e ignore os prompts. Em vez disso, você verá imediatamente a saída de opções determinadas.

Tela do terminal com o resultado do comando executado.

Como gravar a lógica

Agora que podemos determinar as respectivas opções por meio de prompts e argumentos de linha de comando, vamos gravar a lógica real que cria os projetos. Nossa CLI grava em um diretório existente semelhante ao npm init e copia todos os arquivos de um diretório templates no projeto. Vamos permitir que o diretório de destino também seja modificado por meio das opções, caso queira reutilizar a mesma lógica dentro de outro projeto.

Antes de gravar a lógica real, crie um diretório templates na raiz do projeto e coloque dois diretórios com os nomes typescript e javascript. Essas são as versões em letras minúsculas dos dois valores escolhidos pelo usuário. Esta publicação usa esses nomes, mas fique à vontade para usar outros nomes de sua escolha. Dentro desse diretório, insira qualquer package.json que você gostaria de usar como base do seu projeto e qualquer tipo de arquivo que queira copiar para o projeto. Mais tarde, nosso código simplesmente copia esses arquivos no novo projeto. Se precisar de inspiração, você pode conferir meus arquivos em https://github.com/dkundel/create-project.

Para fazer a cópia recursiva dos arquivos, usamos uma biblioteca chamada ncp. Essa biblioteca oferece suporte para a cópia recursiva entre plataformas e até mesmo tem um sinalizador para forçar a substituição de arquivos existentes. Além disso, instalamos chalk para saída colorida. Para instalar as dependências, execute:

npm install ncp chalk

Colocaremos toda a lógica principal em um arquivo main-js dentro do diretório src/ do projeto. Crie o novo arquivo e adicione o seguinte código:

import chalk from 'chalk';
import fs from 'fs';
import ncp from 'ncp';
import path from 'path';
import { promisify } from 'util';

const access = promisify(fs.access);
const copy = promisify(ncp);

async function copyTemplateFiles(options) {
 return copy(options.templateDirectory, options.targetDirectory, {
   clobber: false,
 });
}

export async function createProject(options) {
 options = {
   ...options,
   targetDirectory: options.targetDirectory || process.cwd(),
 };

 const currentFileUrl = import.meta.url;
 const templateDir = path.resolve(
   new URL(currentFileUrl).pathname,
   '../../templates',
   options.template.toLowerCase()
 );
 options.templateDirectory = templateDir;

 try {
   await access(templateDir, fs.constants.R_OK);
 } catch (err) {
   console.error('%s Invalid template name', chalk.red.bold('ERROR'));
   process.exit(1);
 }

 console.log('Copy project files');
 await copyTemplateFiles(options);

 console.log('%s Project ready', chalk.green.bold('DONE'));
 return true;
}

Esse código exporta uma nova função chamada createProject, que primeiro verifica se o modelo especificado é realmente um modelo disponível, verificando o acesso read (fs.constants.R_OK) usando fs.access e, em seguida, copia os arquivos para o diretório de destino usando ncp. Além disso, registramos alguma saída colorida informando DONE Project ready quando copiarmos os arquivos com êxito.

Depois, atualize cli.js para chamar a nova função createProject:

import arg from 'arg';
import inquirer from 'inquirer';
import { createProject } from './main';

function parseArgumentsIntoOptions(rawArgs) {
// ...
}

async function promptForMissingOptions(options) {
// ...
}

export async function cli(args) {
 let options = parseArgumentsIntoOptions(args);
 options = await promptForMissingOptions(options);
 await createProject(options);
}

Para testar nosso progresso, crie um novo diretório em algum lugar como ~/test-dir no sistema e execute dentro dele o comando usando um de seus modelos. Por exemplo:

create-project typescript --git

Aparece uma confirmação de que o projeto foi criado e os arquivos devem ser copiados para o diretório.

Tela do terminal com o resultado do comando executado.

Agora, há mais duas etapas que queremos fazer na nossa CLI. Opcionalmente, queremos inicializar o git e instalar nossas dependências. Para isso, usaremos mais três dependências:

  • execa que permite executar facilmente comandos externos como git
  • pkg-install para acionar yarn install ou npm install, dependendo do que o usuário usa
  • listr que permite especificar uma lista de tarefas e fornecer ao usuário uma visão geral clara do progresso

Instale as dependências ao executar:

npm install execa pkg-install listr

Depois, atualize main.js para conter o seguinte código:

import chalk from 'chalk';
import fs from 'fs';
import ncp from 'ncp';
import path from 'path';
import { promisify } from 'util';
import execa from 'execa';
import Listr from 'listr';
import { projectInstall } from 'pkg-install';

const access = promisify(fs.access);
const copy = promisify(ncp);

async function copyTemplateFiles(options) {
 return copy(options.templateDirectory, options.targetDirectory, {
   clobber: false,
 });
}

async function initGit(options) {
 const result = await execa('git', ['init'], {
   cwd: options.targetDirectory,
 });
 if (result.failed) {
   return Promise.reject(new Error('Failed to initialize git'));
 }
 return;
}

export async function createProject(options) {
 options = {
   ...options,
   targetDirectory: options.targetDirectory || process.cwd()
 };

 const templateDir = path.resolve(
   new URL(import.meta.url).pathname,
   '../../templates',
   options.template
 );
 options.templateDirectory = templateDir;

 try {
   await access(templateDir, fs.constants.R_OK);
 } catch (err) {
   console.error('%s Invalid template name', chalk.red.bold('ERROR'));
   process.exit(1);
 }

 const tasks = new Listr([
   {
     title: 'Copy project files',
     task: () => copyTemplateFiles(options),
   },
   {
     title: 'Initialize git',
     task: () => initGit(options),
     enabled: () => options.git,
   },
   {
     title: 'Install dependencies',
     task: () =>
       projectInstall({
         cwd: options.targetDirectory,
       }),
     skip: () =>
       !options.runInstall
         ? 'Pass --install to automatically install dependencies'
         : undefined,
   },
 ]);

 await tasks.run();
 console.log('%s Project ready', chalk.green.bold('DONE'));
 return true;
}

Assim, executa git init sempre que --git for transferido ou o usuário escolher git no prompt e executa npm install ou yarn sempre que o usuário transferir --install, caso contrário, ele ignora a tarefa com uma mensagem informando ao usuário para transferir --install se quiser a instalação automática.

Tente excluir primeiro sua pasta de teste existente e criar uma nova. Em seguida, execute:

create-project typescript --git --install

Agora você visualiza uma pasta .git na pasta que indica que o git foi inicializado e uma pasta node_modules com suas dependências especificadas no package.json instalado.

Tela do terminal com o resultado do comando executado.

Parabéns, você já está pronto para a sua primeira CLI!

GIF tartarugas ninja fazendo um 'high five' e escrito COWABUNGA.

Se quiser tornar seu código consumível como um módulo real para que outros possam reutilizar sua lógica no código, teremos que adicionar um arquivo index.js ao diretório /src que expõe o conteúdo de main.js:

require = require('esm')(module);
require('../src/cli').cli(process.argv);

O que vem a seguir?

Agora que você já tem seu código CLI pronto, você tem algumas opções a partir daqui. Se quiser apenas para uso próprio e não quiser compartilhar com o mundo, é possível continuar com a opção de usar npm link. Na verdade, tente executar npm init project e ele deve acionar seu código.

Se quiser compartilhar seus templates (modelos) com o mundo, envie seu código para o GitHub e use-o a partir daí ou, ainda melhor, envie-o como um pacote com escopo para o registro npm com o npm publish. Antes disso, verifique se adiciona uma chave files no seu package.json para especificar quais arquivos devem ser publicados.

 },
 "files": [
   "bin/",
   "src/",
   "templates/"
 ]
}

Se quiser verificar quais arquivos são publicados, execute npm pack --dry-run e verifique a saída. Depois, use npm publish para publicar sua CLI. Você pode encontrar meu projeto em @dkundel/create-project ou tentar executar npm init @dkundel/project.

Há também muitas funcionalidades que você pode adicionar. No meu caso, adicionei algumas dependências que criam um arquivo LICENSECODE_OF_CONDUCT.md e .gitignore para mim. Você pode encontrar o código-fonte no GitHub ou verificar algumas das bibliotecas mencionadas acima para obter outras funcionalidades. Se tiver uma biblioteca não listada e acha que não deveria ficar de fora da lista, ou se quiser me mostrar sua própria CLI, fique à vontade para enviar uma mensagem pelo e-mail lleao@twilio.com!

Este artigo foi traduzido do original "How to build a CLI with Node.js". Enquanto melhoramos nossos processos de tradução, adoraríamos receber seus comentários em help@twilio.com - contribuições valiosas podem render brindes da Twilio.