Como criar um aplicativo CLI do Nó do mundo real com o Node

A linha de comando é uma interface de usuário que não recebe atenção suficiente no mundo do desenvolvimento de JavaScript. A realidade é que a maioria das ferramentas dev deve ter uma CLI para ser utilizada por nerds como nós, e a experiência do usuário deve ser parecida com a de seu aplicativo da web meticulosamente criado. Isso inclui um design agradável, menus úteis, mensagens de erro e saídas limpas, indicadores de carga e barras de progresso e assim por diante.

Não há muitos tutoriais do mundo real quando se trata de construir interfaces de linha de comando com o Node, então este é o primeiro de uma série que vai além de um aplicativo CLI básico de “hello world”. Criaremos um aplicativo chamado outside-cli , que fornecerá a previsão atual do tempo e de 10 dias para qualquer local.

Nota : Existem várias bibliotecas por aí que ajudam na criação de CLIs complexas, como oclif , yargs e commander , mas vamos manter nossas dependências escassas por causa deste exemplo, para que você possa entender melhor como as coisas estão funcionando sob o capô. Este tutorial pressupõe que você tenha um conhecimento básico de trabalho sobre JavaScript e Node.

Começando

Como em todos os projetos JavaScript, criar um pacote.json e um arquivo de entrada é a melhor maneira de começar. Podemos mantê-lo simples – não são necessárias dependências ainda.

package.json

 { 
"name": "outside-cli",
"version": "1.0.0",
"license": "MIT",
"scripts": {},
"devDependencies": {},
"dependencies": {}
}

index.js

 module.exports = () => { 
console.log('Welcome to the outside!')
}

Precisamos de uma maneira de invocar nosso aplicativo recém-criado e mostrar a mensagem de boas-vindas, bem como adicioná-la ao caminho do sistema para que ela possa ser chamada de qualquer lugar. Um arquivo bin é a maneira de fazer isso.

 #!/usr/bin/env node 
require('../')()

Nunca vi o #!/usr/bin/env node antes? É chamado de shebang . Ele basicamente diz ao sistema que este não é um script de shell e deve usar um interpretador diferente.

É importante manter o arquivo binário magro, já que seu único propósito é invocar o aplicativo. Todo o nosso código deve viver fora do binário para que ele possa permanecer modular e testável. Também ajudará se quisermos fornecer acesso programático à nossa biblioteca no futuro.

Para executar o arquivo bin diretamente, precisaremos dar a ele as permissões corretas do sistema de arquivos. Se você estiver no UNIX, isso é tão fácil quanto executar chmod +x bin/outside . Se você estiver no Windows, faça um favor e use o subsistema Linux.

Em seguida, adicionaremos nosso binário ao arquivo package.json. Isso irá colocá-lo automaticamente no caminho do sistema do usuário quando ele instalar nosso pacote como global ( npm install -g outside-cli ).

package.json

 { 
"name": "outside-cli",
"version": "1.0.0",
"license": "MIT",
"bin": {
"outside": "bin/outside"
},
"scripts": {},
"devDependencies": {},
"dependencies": {}
}

Agora podemos chamar nosso arquivo bin diretamente executando ./bin/outside . Você deve ver a mensagem de boas vindas. Executar o npm link na raiz do seu projeto irá npm link o seu arquivo binário ao caminho do sistema, tornando-o acessível de qualquer lugar, executando outside .

Quando você executa um aplicativo CLI, ele consiste em argumentos e comandos. Argumentos (ou “flags”) são os valores prefixados com um ou dois hífens (como -d , --debug ou --env production ) e são úteis para passar opções ao nosso aplicativo. Comandos são todos os outros valores que não possuem um sinalizador.

Ao contrário dos comandos, os argumentos não precisam ser especificados em nenhuma ordem específica. Por exemplo, podemos outside today Brooklyn correndo do outside today Brooklyn e assumir que o segundo comando sempre será o local – mas não seria melhor outside today --location Brooklyn no outside today --location Brooklyn , caso desejemos adicionar mais opções no futuro?

Para que nosso aplicativo seja útil, precisamos analisar esses comandos e argumentos e transformá-los em um objeto. Nós poderíamos sempre pular para o process.argv e tentar fazer isso sozinhos, mas vamos instalar nossa primeira dependência chamada minimist para cuidar disso para nós.

 npm install --save minimist 

index.js

 const minimist = require('minimist') 

module.exports = () => {
const args = minimist(process.argv.slice(2))
console.log(args)
}

Nota : A razão pela qual removemos os dois primeiros argumentos com .slice(2) é porque o primeiro argumento sempre será o interpretador seguido pelo nome do arquivo que está sendo interpretado. Nós só nos preocupamos com os argumentos depois disso.

Agora correndo para outside today deve produzir { _: ['today'] } . Se você outside today --location "Brooklyn, NY" , deve sair { _: ['today'], location: 'Brooklyn, NY' } . Vamos aprofundar mais tarde os argumentos quando usarmos a localização, mas por enquanto isso é suficiente para configurar nosso primeiro comando.

Sintaxe do Argumento

Para entender melhor como a sintaxe do argumento funciona, você pode ler isso . Basicamente, um flag pode ser single ou double hifenizado, e terá o valor imediatamente a seguir no comando ou será igual a true quando não houver valor. Bandeiras com um único hífen também podem ser combinadas para booleanos de curta distância ( -a -b -c ou -abc lhe dariam { a: true, b: true, c: true } .)

É importante lembrar que os valores devem ser citados se contiverem caracteres especiais ou um espaço . Rodando --foo bar baz lhe daria '{: [' baz '], foo:' bar '} , but running –foo "bar baz" would give you {foo:' bar baz '} `._

É uma boa ideia dividir o código para cada comando e apenas carregá-lo na memória quando for chamado. Isso cria tempos de inicialização mais rápidos e evita que módulos desnecessários sejam carregados. Fácil o suficiente com uma declaração de switch no comando principal dado a nós pelo minimist. Usando essa configuração, cada arquivo de comando deve exportar uma função e, nesse caso, estamos passando os argumentos para cada comando, para que possamos usá-los posteriormente.

index.js

 const minimist = require('minimist') 

module.exports = () => {
const args = minimist(process.argv.slice(2))
const cmd = args._[0]

switch (cmd) {
case 'today':
require('./cmds/today')(args)
break
default:
console.error(`"${cmd}" is not a valid command!`)
break
}
}

cmds / today.js

 module.exports = (args) => { 
console.log('today is sunny')
}

Agora, se você correr para outside today , verá a mensagem "hoje está ensolarado" e, se você correr para outside foobar , ele informará que "foobar" não é um comando válido. Ainda precisamos consultar uma API de previsão do tempo para obter dados reais, mas esse é um bom começo.

Existem alguns comandos e argumentos que devem estar em todas as CLI: help , --help e -h , que devem mostrar menus de ajuda e version , --version e -v que devem mostrar a version atual do aplicativo. Também devemos usar como padrão um menu de ajuda principal, se nenhum comando for especificado.

Isso pode ser facilmente implementado em nossa configuração atual, adicionando dois casos à nossa instrução switch, um valor padrão para a variável cmd e implementando algumas instruções if para os sinalizadores de argumento de ajuda e versão. O Minimist analisa automaticamente os argumentos para key / values, portanto, a execução de outside --version tornará args.version igual a true.

 const minimist = require('minimist') 

module.exports = () => {
const args = minimist(process.argv.slice(2))

let cmd = args._[0] || 'help'

if (args.version || args.v) {
cmd = 'version'
}

if (args.help || args.h) {
cmd = 'help'
}

switch (cmd) {
case 'today':
require('./cmds/today')(args)
break

case 'version':
require('./cmds/version')(args)
break

case 'help':
require('./cmds/help')(args)
break

default:
console.error(`"${cmd}" is not a valid command!`)
break
}
}

Para implementar nossos novos comandos, siga o mesmo formato do comando today .

cmds / version.js

 const { version } = require('../package.json') 

module.exports = (args) => {
console.log(`v${version}`)
}

cmds / help.js

 const menus = { 
main: `
outside [command] <options>

today .............. show weather for today
version ............ show package version
help ............... show help menu for a command`,

today: `
outside today <options>

--location, -l ..... the location to use`,
}

module.exports = (args) => {
const subCmd = args._[0] === 'help'
? args._[1]
: args._[0]

console.log(menus[subCmd] || menus.main)
}

Agora, se você executar outside help today ou outside today -h , você deverá ver o menu de ajuda do comando today . Correndo outside ou outside -h deve mostrar-lhe o menu de ajuda principal.

Esta configuração do projeto é realmente incrível, porque se você precisar adicionar um novo comando, tudo o que você precisa fazer é criar um novo arquivo na pasta cmds , adicioná-lo à instrução switch e adicionar um menu de ajuda, se houver um.

cmds / forecast.js

 module.exports = (args) => { 
console.log('tomorrow is rainy')
}

index.js

 // ... 
case 'forecast':
require('./cmds/forecast')(args)
break
// ...

cmds / help.js

 const menus = { 
main: `
outside [command] <options>

today .............. show weather for today
forecast ........... show 10-day weather forecast
version ............ show package version
help ............... show help menu for a command`,

today: `
outside today <options>

--location, -l ..... the location to use`,

forecast: `
outside forecast <options>

--location, -l ..... the location to use`,
}

// ...

Às vezes, um comando pode levar muito tempo para ser executado. Se você está buscando dados de uma API, gerando conteúdo, gravando arquivos no disco ou qualquer outro processo que demore mais que alguns milissegundos, você quer dar ao usuário algum feedback de que seu aplicativo não congelou e está simplesmente funcionando. Difícil. Às vezes você pode medir o progresso de sua operação e faz sentido mostrar uma barra de progresso, mas outras vezes é mais variável e faz sentido mostrar um indicador de carregamento.

Para nosso aplicativo, não podemos medir o progresso de nossas solicitações de API. Por isso, usaremos um giratório básico para mostrar que algo está acontecendo. Instale mais duas dependências para nossos pedidos de rede e nosso girador:

 npm install --save axios ora 

Buscando dados da API

Agora, vamos criar um utilitário que faça uma solicitação à API de clima do Yahoo para as condições atuais e a previsão de um local.

Nota : A API do Yahoo usa a sintaxe “YQL”, e é um pouco funky – não tente entendê-la, basta copiar e colar. Essa foi a única API de clima que eu encontrei que não exigia uma chave de API.

utils / weather.js

 const axios = require('axios') 

module.exports = async (location) => {
const results = await axios({
method: 'get',
url: 'https://query.yahooapis.com/v1/public/yql',
params: {
format: 'json',
q: `select item from weather.forecast where woeid in
(select woeid from geo.places(1) where text="${location}")`,
},
})

return results.data.query.results.channel.item
}

cmds / today.js

 const ora = require('ora') 
const getWeather = require('../utils/weather')

module.exports = async (args) => {
const spinner = ora().start()

try {
const location = args.location || args.l
const weather = await getWeather(location)

spinner.stop()

console.log(`Current conditions in ${location}:`)
console.log(`t${weather.condition.temp}° ${weather.condition.text}`)
} catch (err) {
spinner.stop()

console.error(err)
}
}

Agora, se você correr para outside today --location "Brooklyn, NY" , você verá um rápido spinner enquanto faz o pedido, seguido pelas condições climáticas atuais.

Como a solicitação acontece tão rapidamente, pode ser difícil ver o indicador de carregamento. Se você quiser desacelerá-lo manualmente para vê-lo, você pode adicionar esta linha ao início de sua função utilitária: await new Promise(resolve => setTimeout(resolve, 5000)) .

Ótimo! Agora vamos copiar o código para o nosso comando forecast e alterar um pouco a formatação.

cmds / forecast.js

 const ora = require('ora') 
const getWeather = require('../utils/weather')

module.exports = async (args) => {
const spinner = ora().start()

try {
const location = args.location || args.l
const weather = await getWeather(location)

spinner.stop()

console.log(`Forecast for ${location}:`)
weather.forecast.forEach(item =>
console.log(`t${item.date} - Low: ${item.low}° | High: ${item.high}° | ${item.text}`))
} catch (err) {
spinner.stop()

console.error(err)
}
}

Agora você pode ver uma previsão do tempo de 10 dias ao executar a outside forecast --location "Brooklyn, NY" . Parece bom! Vamos adicionar mais um utilitário para obter automaticamente nossa localização com base em nosso endereço IP, se nenhum local for especificado no comando.

utils / location.js

 const axios = require('axios') 

module.exports = async () => {
const results = await axios({
method: 'get',
url: 'https://api.ipdata.co',
})

const { city, region } = results.data
return `${city}, ${region}`
}

cmds / today.js & cmds / forecast.js

 // ... 
const getLocation = require('../utils/location')

module.exports = async (args) => {
// ...
const location = args.location || args.l || await getLocation()
const weather = await getWeather(location)
// ...
}

Agora, se você simplesmente executar a outside forecast sem um local, verá a previsão da sua localização atual.

Tratamento de erros

Eu não entrei em muitos detalhes sobre como lidar melhor com os erros (isso virá em um tutorial posterior), mas o mais importante a ser lembrado é usar os códigos de saída corretos.

Se sua CLI tiver um erro crítico, você deve sair com process.exit(1) . Isso permite que o terminal saiba que o programa não saiu corretamente, o que o notificará de um serviço de IC, por exemplo.

Vamos criar um utilitário rápido que faça isso para nós, para que possamos obter o código de saída correto quando um comando não existente for executado.

utils / error.js

 module.exports = (message, exit) => { 
console.error(message)
exit && process.exit(1)
}

index.js

 // ... 
const error = require('./utils/error')

module.exports = () => {
// ...
default:
error(`"${cmd}" is not a valid command!`, true)
break
// ...
}

Terminando

O último passo para colocar nossa biblioteca em destaque é publicá-la em um gerenciador de pacotes. Como nosso aplicativo é escrito em JavaScript, faz sentido publicar no NPM. Vamos preencher o nosso package.json um pouco mais:

 { 
"name": "outside-cli",
"version": "1.0.0",
"description": "A CLI app that gives you the weather forecast",
"license": "MIT",
"homepage": "https://github.com/timberio/outside-cli#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/timberio/outside-cli.git"
},
"engines": {
"node": ">=8"
},
"keywords": [
"weather",
"forecast",
"rain"
],
"preferGlobal": true,
"bin": {
"outside": "bin/outside"
},
"scripts": {},
"devDependencies": {},
"dependencies": {
"axios": "^0.18.0",
"minimist": "^1.2.0",
"ora": "^2.0.0"
}
}
  • A configuração do engine garantirá que qualquer pessoa que instale nosso aplicativo tenha uma versão atualizada do Node. Como estamos usando a sintaxe async / await sem transpilação, estamos exigindo o Node 8.0 ou superior.
  • A configuração preferGlobal avisará o usuário se estiver instalando com npm install --save vez de npm install --global .

É isso aí! Agora você pode executar o npm publish e seu aplicativo estará disponível para download. Se você quiser dar um passo adiante e lançar em outros gerenciadores de pacotes (como o Homebrew), você pode verificar o pkg ou o nexe , que ajuda você a empacotar seu aplicativo em um binário independente.

Resumo

Esta é a estrutura que seguimos para todos os nossos aplicativos CLI aqui na Timber , e isso ajuda a manter as coisas organizadas e modulares.

Algumas dicas importantes deste tutorial para aqueles que apenas o skimmed:

  • Os arquivos Bin são o ponto de entrada para qualquer aplicativo CLI e devem invocar apenas a função principal
  • Arquivos de comando não devem ser necessários até que sejam necessários
  • Sempre inclua comandos de help e version
  • Mantenha os arquivos de comando pequenos – seu objetivo principal é chamar funções e mostrar mensagens do usuário
  • Mostrar sempre algum tipo de indicador de atividade
  • Saia com os códigos de erro corretos

Espero que você tenha uma melhor compreensão de como criar e organizar aplicativos CLI no Node. Esta é a primeira parte de uma série de tutoriais, então volte mais tarde, à medida que aprofundamos a adição de design, arte ascii e cor, aceitando a entrada do usuário, escrevendo testes de integração e muito mais. Você pode ver todo o código fonte que escrevemos hoje no GitHub .

Somos uma empresa madeireira baseada em nuvem aqui @ Timber . Nós adoraríamos se você experimentasse nosso produto (seriamente ótimo! – você pode criar uma conta gratuita aqui ), mas é só isso que vamos divulgar nosso produto … vocês vieram aqui para aprender a construir um Aplicativo CLI em Nó e esperamos que este guia tenha ajudado você a começar.