Criando uma CLI com Gluegun

Quando comecei a implementar o Replicante (o meu primeiro projeto que considero verdadeiramente open source), precisei de uma ferramenta que me permitisse implementar uma CLI (command line interface) apresentável, fácil de usar, e fácil de desenvolver. Encontrei tudo isso no Gluegun.

Gluegun?

Gluegun é um toolkit para desenvolvimento de apps de linha de comando escritos em TypeScript. O nome é bem condizente com a sua construção, pois ele cola com maestria uma série de excelentes bibliotecas para resolver os principais problemas de um app de linha de comando.

Ele inclui ferramentas para lidar com todos essas classes de problemas:

  • parameters - para trabalhar com parâmetros de linhas de comando (argumentos e opções), usando o yargs-parser ou enquirer
  • templates - para gerar arquivos a parter de templates, usando ejs
  • patching - para aplicar patches em arquivos, manipulando o conteúdo
  • filesystem - para mover arquivos e pastas por aí, usando o fs-jetpack
  • system -para executar outros scripts de linha de comando, usando o execa ou cross-spawn
  • http - para interagir com APIs, usando axios e apisauce
  • prompt - para gerar prompts com opção de auto-completar
  • print - para imprimir textos e tabelas coloridos, usando colors, ora ou cli-table3
  • semver - para trabalhar com versionamento semântico, usando o semver
  • strings - para manipular strings e dados de templates, com várias funções auxiliares do pluralize
  • packageManager - para instalar pacotes NPM com Yarn ou NPM

Outra funcionalidade bacana do Gluegun é sua extensibilidade: ele nos permite escrever nossos próprios plugins e extensões de forma bem fácil.

Quem usa o Gluegun?

Existem alguns apps de peso que foram implementados com o Gluegun. Dentre eles, vale citar o AWS Amplify CLI, para desenvolvimento de aplicações serverless, e o Ignite CLI, um starter kit e CLI para React Native

Criando nosso primeiro app

Alguns dos exemplos que vou apresentar no resto do tutorial foram adaptados e simplificados dos códigos do Replicante.

Mas primeiro precisamos instalar o Gluegun, e essa é uma tarefa fácil. Basta instalar via Yarn assim:

yarn global add gluegun

E agora estamos prontos para criar nosso primeiro app com o comando gluegun new <nomeprojeto>. Para o Replicante, usei o comando:

gluegun new replicante

O Gluegun vai perguntar se queremos criar o app em Javascript Moderno (Node 8.2+) ou TypeScript. Particularmente, creio que essa é a melhor opção, pois já traz uma pipeline de build funcionando.

Print do console no momento em que o Gluegun pede para escolher entre Typescript ou Javascript

Agora você pode navegar para a pasta do seu novo app, no meu caso, replicante, usando ‘cd replicante’, e criar um link simbólico para o executável usando o comando yarn link.

A partir daí, você já pode rodar o seu app usando o próprio nome da ferramenta. No meu caso, se eu rodar o comando replicante, vou ver algo assim:

Primeira interação com a CLI, dado as boas vindas, e sugerindo o uso do comando --help

A estrutura de um projeto com Gluegun

O projeto criado para nós pela ferramenta segue uma estrutura bem fácil de acompanhar:

Estrutura de diretórios da aplicação, contendo pastas para comandos, extensões, documentação, templates EJS e testes

Na pasta commands, o Gluegun já traz dois comandos de exemplo para nos ajudar. O primeiro deles é o generate, um exemplo de geração de arquivos baseados em templates EJS, que pode ser muito útil numa variedade de cenários de desenvolvimento, como por exemplo, a criação de componentes React.

Print da tela mostrando um comando simples de geração de arquivo com base num simples template ejs, que imprime uma palavra

Já o segundo, é o próprio comando replicante, que executamos há pouco, e imprime a mensagem “Welcome to your CLI”.

Print da tela mostrando um comando padrão para ser chamado quando alguém rodar eplicante` no terminal

Uma peça importante, onde a mágica começa, é o arquivo cli.ts. Ele é responsável por instanciar o runtime que vai rodar a aplicação. Nessa etapa, podemos configurar os plugins, os locais dos fontes e habilitar comandos padrão como help e version:

const { build } = require('gluegun')

/**
 * Create the cli and kick it off
 */
async function run(argv) {
  // create a CLI runtime
  const cli = build()
    .brand('replicante')
    .src(__dirname)
    .plugins('./node_modules', { matching: 'replicante-*', hidden: true })
    .help() // provides default for help, h, --help, -h
    .version() // provides default for version, v, --version, -v
    .create()
  // enable the following method if you'd like to skip loading one of these core extensions
  // this can improve performance if they're not necessary for your project:
  // .exclude(['meta', 'strings', 'print', 'filesystem', 'semver', 'system', 'prompt', 'http', 'template', 'patching', 'package-manager'])
  const toolbox = await cli.run(argv)

  // send it back (for testing, mostly)
  return toolbox
}

module.exports = { run }

Além disso, ele já gera alguns cenários de teste básicos com Jest, no arquivo __tests__/cli-integration.test.ts, contemplando a ajuda, a versão e a geração de arquivos com base em templates. Uma mão na roda para começar:

const { system, filesystem } = require('gluegun')

const src = filesystem.path(__dirname, '..')

const cli = async cmd =>
  system.run('node ' + filesystem.path(src, 'bin', 'replicante') + ` ${cmd}`)

test('outputs version', async () => {
  const output = await cli('--version')
  expect(output).toContain('0.0.1')
})

test('outputs help', async () => {
  const output = await cli('--help')
  expect(output).toContain('0.0.1')
})

test('generates file', async () => {
  const output = await cli('generate foo')

  expect(output).toContain('Generated file at models/foo-model.ts')
  const foomodel = filesystem.read('models/foo-model.ts')

  expect(foomodel).toContain(`module.exports = {`)
  expect(foomodel).toContain(`name: 'foo'`)

  // cleanup artifact
  filesystem.remove('models')
})

Implementando o Replicante

Nesta seção, vou falar um pouco sobre como usei o Gluegun para tornar o Replicante uma realidade.

Resumidamente, o Replicante é um super copiador de projetos. Você diz pra ele em qual dos seus projetos quer se basear, e quais transformações aplicar (geralmente, substituir termos específicos e namespaces), e ele gera um novo com as modificações aplicadas. Muito útil para freelancers que querem reaproveitar projetos anteriores nos seus novos projetos.

Acho interessante começar pelo cli.ts, e explorar como ele ficou:

const { build } = require('gluegun')

async function run(argv) {
  // create a CLI runtime
  const cli = build()
    .brand('replicante')
    .src(__dirname)
    .plugins('./node_modules', { matching: 'replicante-*', hidden: true })
    .help() // provides default for help, h, --help, -h
    .version() // provides default for version, v, --version, -v
    .defaultCommand()
    .exclude(['semver', 'prompt', 'http', 'package-manager'])
    .create()
  
  // run it
  const toolbox = await cli.run(argv)

  // send it back (for testing, mostly)
  return toolbox
}

module.exports = { run }

O código de inicialização acima possui algumas diferenças em relação ao primeiro exemplo que mostrei anteriormente.

A primeira mudança foi a adição da função defaultCommand(). Ela foi adicionada para permitir remover a implementação do script de comando replicante.ts, criado junto com o app. No lugar dele, o Gluegun cuida de imprimir as boas vidas, e instruir o usuário sobre o comando help:

Print da tela mostrando um comando padrão para ser chamado quando alguém rodar replicante no terminal, desta vez listando a versão e instruindo o usuário a chamar o comando help

Em seguida, foi adicionada a função exclude, para a qual eu passei uma lista de módulos do toolkit do Gluegun que a CLI do Replicante não usa, e que não devem ser carregados na inicialização do app. Assim, ele carrega mais rápido e a experiência do usuário melhora significativamente.

A lista completa de módulos que podem ser excluídos vem já na forma de comentário quando o cli.ts é criado, e serve para consulta, mas por questão de simplicidade vou replicá-la aqui:

  .exclude(['meta', 'strings', 'print', 'filesystem', 'semver',
    'system', 'prompt', 'http', 'template', 'patching', 'package-manager'])

Criei então um novo comando, chamado create.ts na pasta commands. Na declaração do comando, defini o nome e a descrição do mesmo.

import { GluegunCommand, filesystem, strings } from 'gluegun'

const command: GluegunCommand = {
  name: 'create',
  description:'Create a REPLICANT by applying the RECIPE instructions to the SAMPLE',
  run: async toolbox => {
    //...
  }
}

module.exports = command

Como pode ser viso no trecho acima, a função run recebe um toolbox. Este toolbox pode ser desconstruído para obtermos as ferramentas necessárias para o nosso comando:

const command: GluegunCommand = {
  name: 'create',
  description:'Create a REPLICANT by applying the RECIPE instructions to the SAMPLE',
  run: async toolbox => {
    const {
      parameters,
      print: { success, info, error },
      patching
    } = toolbox
    //...
  }
}

Um ponto interessante de destacar, é que quaisquer imports feitos fora do comando serão carregados junto com o app. No entanto, algumas dependências são específicas de um comando ou outro, e só precisam ser carregadas se o comando for ativado. Podemos fazer isso carregando esses módulos dentro da função run usando a função require:

const command: GluegunCommand = {
  name: 'create',
  description:'Create a REPLICANT by applying the RECIPE instructions to the SAMPLE',
  run: async toolbox => {
    // esses caras só vão ser carregados se usuário rodar o comando "create"
    const fs = require('fs')
    const path = require('path')
    const { generateReplicant } = require('../replication/replication-process')

    const {
      parameters,
      print: { success, info, error },
      patching
    } = toolbox

    // ...
  }
}

O resto do código do comando create trata da regra de negócio do Replicante, então não vou entrar em muitos detalhes.

A título de curiosidade, abaixo copiei a função printHelp, que imprime a ajuda do comando create. Nela, podemos ver como usei as funções info e table para imprimir uma ajuda minimamente decente:

const printHelp = toolbox => {
  const {
    print: { info, table }
  } = toolbox

  const printInstructionLines = instructions => {
    instructions.forEach(instructionLine => info(instructionLine))
  }

  printInstructionLines([
    'You can run "replicante create <path-to-sample> <path-to-recipe> [options]" to create your replicant.',
    '',
    'These are the available options:'
  ])

  table(
    [
      ['Option', 'Description'],
      [
        '--target',
        'The directory where the Replicant should be created. Default value: <USER-HOME>/.replicante/<replicant-name>'
      ]
    ],
    { format: 'markdown' }
  )

  printInstructionLines([
    '',
    'A quick glossary:',
    '- sample: the project folder you want to replicate, with some adjustments',
    '- recipe: contains instructions on which terms from recipe files to replace by new terms',
    '- replicant: the resulting project, with all terms defined in the recipe replaced',
    '',
    'Some common use cases  you may find "replicante" useful for:',
    '- Replacing import paths in Python or Javascript projects',
    '- Ajusting C# namespaces or Java packages',
    '- Replacing the project name, that repeats itself in many ways in configuration files',
    '',
    'For more information on how to use "replicante", visit: https://github.com/DyegoMaas/Replicante'
  ])
}

A verificação do parâmetro --help é a primeira coisa feita no comando create:

const command: GluegunCommand = {
  name: 'create',
  description:'Create a REPLICANT by applying the RECIPE instructions to the SAMPLE',
  run: async toolbox => {
    const fs = require('fs')
    const path = require('path')
    const { generateReplicant } = require('../replication/replication-process')
    const {
      parameters,
      print: { success, info, error },
      patching
    } = toolbox

    // se o usuário informar --help ou help ou -h ou h (replicante create --help)
    if (parameters.options.help) {
      printHelp(toolbox)
      return
    }

    // ...

E abaixo, como sai a ajuda no terminal:

Print da tela mostrando a ajuda do comando create

Incluindo arquivos Javascript no build

Quando fui implementar a publicação do Replicante no NPM, a primeira versão saiu incompleta, faltando alguns fontes mesmo.

Print destacando o diretório replication, que continha fontes javascript que não eram incluídos no build

Fontes JS que faltavam no build

Isso porque partes do projeto ainda estão implementadas em Javascript, e não são incluídas automaticamente no build da aplicação. Por isso, precisei fazer um leve ajuste nas tasks no package.json para copiar esses arquivos para a pasta build:

Print mostrando a task copyReplication, que copia a pasta replication para a pasta build

Finalizando

O Gluegun é uma ferramenta fantástica que nos permite criar apps de linha de comando bastante decentes com pouco esforço. É útil tanto para desenvolver apps públicos, como ferramentas internas para automatizar tarefas do dia-a-dia.

Foi útil para a construção do Replicante, e espero que seja útil para você também.

E se você quiser saber mais sobre o Replicante, pode conferir meu artigo sobre ele, e também o repositório no GitHub.

Outras opções para compartilhar:
comments powered by Disqus