Cómo construir una interfaz de línea de comandos (CLI) usando Node.js

Cómo utilizar la biblioteca Commander.js para crear una interfaz de línea de comandos (CLI) que se comunique con la API de marcador de posición JSON.

Primeros pasos

Para este tutorial, vamos a crear un nuevo proyecto de Node.js desde cero. Vamos a suponer que estamos usando la última versión de Node.js (v16) al momento de escribir.

En su computadora, comience creando una carpeta donde vivirá nuestro código CLI:

Terminal

mkdir jsonp

A continuación, cd en la carpeta del proyecto y ejecuta npm init -f para forzar la creación de un package.json archivo para el proyecto:

Terminal

npm init -f

Con un package.json archivo, a continuación, queremos agregar dos dependencias:commander (el paquete que usaremos para estructurar nuestra CLI) y node-fetch que usaremos para ejecutar solicitudes HTTP a la API de marcador de posición JSON:

Terminal

npm i commander node-fetch

Con nuestras dependencias listas, finalmente, queremos modificar nuestro package.json archivo para habilitar la compatibilidad con los módulos de JavaScript agregando el "type": "module" propiedad:

/paquete.json

{
  "name": "jsonp",
  "type": "module",
  "version": "1.0.0",
  ...
}

Con eso, estamos listos para comenzar.

Agregando un indicador bin a su paquete.json

Antes de cerrar nuestro package.json archivo, muy rápido vamos a saltar y agregar el bin propiedad que, cuando se instala nuestro paquete, agregará el valor especificado a la línea de comando de nuestro usuario PATH variables:

/paquete.json

{
  "name": "jsonp",
  "type": "module",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "bin": {
    "jsonp": "index.js"
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "commander": "^8.1.0",
    "node-fetch": "^2.6.1"
  }
}

Aquí, configuramos bin a un objeto con una propiedad jsonp establecido en un valor de index.js . Aquí, jsonp es el nombre con el que se podrá acceder a nuestra CLI como jsonp a través de la línea de comando (por ejemplo, $ jsonp posts ). El index.js parte apunta a la ubicación del script que queremos asociar con ese comando.

Vamos a crear ese index.js presente ahora y comience a construir nuestra CLI. Revisaremos el significado de este bin configuración más adelante en el tutorial.

Configuración del comando CLI principal

Afortunadamente, gracias al commander dependencia que instalamos anteriormente, configurar nuestra CLI es bastante sencillo.

/index.js

#!/usr/bin/env node

import cli from "commander";

cli.description("Access the JSON Placeholder API");
cli.name("jsonp");

cli.parse(process.argv);

Preparándonos, algunas cosas diferentes aquí. Primero, porque nuestro script se ejecutará a través de la línea de comando (por ejemplo, a través de un bash shell o zsh shell), necesitamos agregar lo que se conoce como una línea shebang (no seas espeluznante). Esto le dice a la línea de comando a través de qué intérprete se debe ejecutar el script pasado. En este caso, queremos que Node.js interprete nuestro código.

Entonces, cuando ejecutamos este archivo a través de la línea de comando, su código se entregará a Node.js para su interpretación. Si excluimos esta línea, esperaríamos que la línea de comando arrojara un error ya que no entendería el código.

Debajo de esta línea, profundizamos en nuestro código real. Primero, desde el commander paquete que importamos cli . Aquí, debido a que esperamos una exportación predeterminada (lo que significa que Commander no utiliza un nombre específico internamente para el valor que exporta), lo importamos como cli en lugar de commander para contextualizar mejor el código en nuestro archivo.

A continuación, agregamos una descripción y un nombre con .description() y .name() respectivamente. Preste atención a la sintaxis aquí. Mientras trabajamos con Commander, todo lo que hacemos se basa en la instancia principal de Commander, representada aquí como cli .

Finalmente, en la parte inferior de nuestro archivo, agregamos una llamada a cli.parse() pasando process.argv . process.argv está extrayendo los argumentos pasados ​​a Node.js process (el nombre en memoria de nuestro script una vez cargado) que se almacenan en el argv propiedad en el process objeto. Es importante tener en cuenta que se trata de un Node.js concepto y no tiene nada que ver con Commander.

La parte de Commander es cli.parse() . Este método, como su nombre lo indica, analiza los argumentos pasados ​​a nuestro script. Desde aquí, Commander toma los argumentos pasados ​​a la secuencia de comandos e intenta interpretarlos y compararlos con los comandos y opciones de nuestra CLI.

Aunque no esperamos que suceda nada todavía, para probar esto, en su línea de comando, cd en la raíz del jsonp carpeta que creamos y ejecutamos node index.js . Si todo está configurado correctamente hasta el momento, el comando debería ejecutarse y regresar sin imprimir nada en la terminal.

Adición de detalles y comandos individuales

Ahora para la parte interesante. A partir de ahora, nuestra CLI es, bueno, inútil. Lo que queremos hacer es agregar comandos individuales que sean parte de la CLI que podamos ejecutar o "ejecutar" para realizar alguna tarea. Una vez más, nuestro objetivo es crear una CLI simple para acceder a la API de marcador de posición JSON. Nos vamos a centrar en tres comandos:

  1. posts recuperará una lista de publicaciones de la API, o una sola publicación (aprenderemos cómo pasar un argumento a nuestros comandos para que esto sea posible).
  2. comments recuperará una lista de comentarios de la API. Deliberadamente mantendremos esto simple para mostrar la variación entre nuestros comandos.
  3. users recuperará una lista de usuarios de la API, o un solo usuario. Esto se comportará de forma idéntica al posts comando, simplemente accediendo a un recurso diferente en la API.

Antes de agregar nuestros comandos, muy rápido, queremos agregar más configuraciones a nivel de CLI para limpiar la experiencia del usuario:

/index.js

#!/usr/bin/env node

import cli from "commander";

cli.description("Access the JSON Placeholder API");
cli.name("jsonp");
cli.usage("<command>");
cli.addHelpCommand(false);
cli.helpOption(false);

cli.parse(process.argv);

Aquí, debajo de nuestra llamada a cli.name() hemos agregado tres configuraciones más:cli.usage() , cli.addHelpCommand() y cli.helpOption() .

El primero, cli.usage() , nos ayuda a agregar las instrucciones de uso en la parte superior de nuestra CLI cuando se invoca a través de la línea de comando. Por ejemplo, si tuviéramos que ejecutar jsonp en nuestra terminal (hipotéticamente hablando), veríamos un mensaje que decía algo como...

Usage: jsonp <command>

Aquí, le sugerimos que use la CLI llamando al jsonp y pasando el nombre de un subcomando que le gustaría ejecutar desde esa CLI.

El .addHelpCommand() el método aquí se está pasando false decir que no quiere que Commander agregue el help predeterminado comando a nuestra CLI. Esto es útil para CLI más complejas, pero para nosotros solo agrega confusión.

Del mismo modo, también configuramos .helpOption() a false para lograr lo mismo, pero en lugar de eliminar un comando de ayuda , eliminamos el -h incorporado o --help indicador de opción.

Ahora, conectemos el posts comando que insinuamos anteriormente y luego vea cómo obtener datos a través de la API de marcador de posición JSON.

/index.js

#!/usr/bin/env node

import cli from "commander";
import posts from "./commands/posts.js";

cli.description("Access the JSON Placeholder API");
cli.name("jsonp");
...

cli
  .command("posts")
  .argument("[postId]", "ID of post you'd like to retrieve.")
  .option("-p, --pretty", "Pretty-print output from the API.")
  .description(
    "Retrieve a list of all posts or one post by passing the post ID (e.g., posts 1)."
  )
  .action(posts);

cli.parse(process.argv);

Nuevamente, todas las modificaciones a nuestra CLI se realizan desde el cli principal. objeto que importamos del commander paquete. Aquí, definimos un comando individual ejecutando cli.command() , pasando el nombre del comando que queremos definir posts . A continuación, utilizando la función de encadenamiento de métodos de Commander (esto significa que podemos ejecutar métodos posteriores uno tras otro y Commander lo entenderá), definimos un .argument() postId . Aquí, pasamos dos opciones:el nombre del argumento (usando el [] sintaxis de corchetes para indicar que el argumento es opcional —los argumentos requeridos usan <> corchetes angulares) y una descripción de la intención de ese argumento.

A continuación, para mostrar los indicadores de opciones, agregamos .option() , pasando primero las versiones corta y larga de la marca separadas por comas (aquí, -p y --pretty ) y luego una descripción de la bandera. En este caso, --pretty se usará internamente en la función relacionada con nuestro comando para decidir si vamos a "imprimir bastante" (es decir, formatear con dos espacios) los datos que obtenemos de la API de marcador de posición JSON.

Para completar la configuración de nuestro comando, llamamos a .description() agregando la descripción que queremos mostrar cuando nuestra CLI se ejecuta sin un comando específico (efectivamente, un manual o una página de "ayuda").

Finalmente, la parte importante, terminamos agregando .action() y pasando la función que queremos llamar cuando se ejecuta este comando. Arriba, hemos importado una función posts desde un archivo en el commands carpeta que agregaremos ahora.

/comandos/posts.js

import fetch from "node-fetch";

export default (postId, options) => {
  let url = "https://jsonplaceholder.typicode.com/posts";

  if (postId) {
    url += `/${postId}`;
  }

  fetch(url).then(async (response) => {
    const data = await response.json();

    if (options.pretty) {
      return console.log(data);
    }

    return console.log(JSON.stringify(data));
  });
};

Para seguir avanzando, aquí hemos agregado el código completo para nuestro posts dominio. La idea aquí es bastante simple. La función que estamos exportando recibirá dos argumentos:postId si se especificó una ID y options que serán banderas como --pretty que fueron pasados.

Dentro de esa función, establecemos la URL base para el /posts punto final en la API de marcador de posición JSON en la variable url , asegurándose de usar el let definición para que podamos sobrescribir condicionalmente el valor. Necesitamos hacer eso en caso de que un postId se pasa. Si hay uno, modificamos el url agregando /${postId} , dándonos una URL actualizada como https://jsonplaceholder.typicode.com/posts/1 (suponiendo que escribimos jsonp posts 1 en la línea de comando).

A continuación, con nuestro url , usamos el fetch() método que importamos de node-fetch arriba pasando nuestro url . Debido a que esperamos que esta llamada devuelva una Promesa de JavaScript, agregamos un .then() método para manejar la respuesta a nuestra solicitud.

Manejando esa respuesta, usamos un patrón JavaScript async/await para await la llamada al response.json() (esto convierte la respuesta sin procesar en un objeto JSON) y luego almacena la respuesta en nuestro data variables.

A continuación, comprobamos si options.pretty está definido (es decir, cuando se ejecutó nuestro comando, el -p o --pretty también se pasó la bandera) y si es así, simplemente registramos el objeto JSON sin formato que acabamos de almacenar en data . Si options.pretty es no pasado, llamamos a JSON.stringify() pasando nuestro data . Esto nos devolverá una versión de cadena comprimida de nuestros datos.

Para probar esto, abre tu terminal y ejecuta lo siguiente:

node index.js posts --pretty

Si todo funciona, debería ver algunos datos provenientes de la API de marcador de posición JSON, impresos en pantalla.

[
  {
    userId: 10,
    id: 99,
    title: 'temporibus sit alias delectus eligendi possimus magni',
    body: 'quo deleniti praesentium dicta non quod\n' +
      'aut est molestias\n' +
      'molestias et officia quis nihil\n' +
      'itaque dolorem quia'
  },
  {
    userId: 10,
    id: 100,
    title: 'at nam consequatur ea labore ea harum',
    body: 'cupiditate quo est a modi nesciunt soluta\n' +
      'ipsa voluptas error itaque dicta in\n' +
      'autem qui minus magnam et distinctio eum\n' +
      'accusamus ratione error aut'
  }
]

Si elimina el --pretty marque desde ese comando y agregue el número 1 (como node index.js posts 1 ), deberías ver la versión en cadena condensada de una sola publicación:

{"userId":1,"id":1,"title":"sunt aut facere repellat provident occaecati excepturi optio reprehenderit","body":"quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"}

Esto se configura con una plantilla para el resto de nuestros comandos. Para concluir, sigamos adelante y agreguemos esos dos comandos (y sus funciones en el /commands directorio) y discuta rápidamente cómo funcionan.

/index.js

#!/usr/bin/env node

import cli from "commander";
import posts from "./commands/posts.js";
import comments from "./commands/comments.js";
import users from "./commands/users.js";

cli.description("Access the JSON Placeholder API");
...

cli
  .command("posts")
  .argument("[postId]", "ID of post you'd like to retrieve.")
  .option("-p, --pretty", "Pretty-print output from the API.")
  .description(
    "Retrieve a list of all posts or one post by passing the post ID (e.g., posts 1)."
  )
  .action(posts);

cli
  .command("comments")
  .option("-p, --pretty", "Pretty-print output from the API.")
  .description("Retrieve a list of all comments.")
  .action(comments);

cli
  .command("users")
  .argument("[userId]", "ID of the user you'd like to retrieve.")
  .option("-p, --pretty", "Pretty-print output from the API.")
  .description(
    "Retrieve a list of all users or one user by passing the user ID (e.g., users 1)."
  )
  .action(users);

cli.parse(process.argv);

Para mostrar varios comandos, aquí hemos agregado dos comandos adicionales:comments y users . Ambos están configurados para hablar con la API de marcador de posición JSON exactamente de la misma manera que nuestro posts comando.

Notarás que users es idéntico a nuestro posts comando—salvo el nombre y la descripción—mientras que el comments al comando le falta un .argument() . Esto es intencional. Queremos mostrar la flexibilidad de Commander aquí y mostrar lo que se requiere y lo que no.

Lo que aprendimos anteriormente todavía se aplica. Los métodos se encadenan uno tras otro, culminando finalmente en una llamada a .action() donde pasamos la función que se llamará cuando nuestro comando se ejecute a través de la línea de comandos.

Echemos un vistazo al comments y users funciona ahora y veamos si podemos detectar alguna diferencia importante:

/comandos/comentarios.js

import fetch from "node-fetch";

export default (options) => {
  fetch("https://jsonplaceholder.typicode.com/comments").then(
    async (response) => {
      const data = await response.json();

      if (options.pretty) {
        return console.log(data);
      }

      return console.log(JSON.stringify(data));
    }
  );
};

Para comments , nuestro código es casi idéntico al que vimos antes con posts con un giro menor:hemos omitido almacenar el url en una variable para que podamos modificarla condicionalmente en función de los argumentos pasados ​​a nuestro comando (recuerde, hemos configurado comments a no esperar cualquier argumento). En su lugar, acabamos de pasar la URL para el punto final de la API de marcador de posición JSON que queremos:/comments —y luego realice exactamente el mismo manejo de datos que hicimos para posts .

/comandos/usuarios.js

import fetch from "node-fetch";

export default (userId, options) => {
  let url = "https://jsonplaceholder.typicode.com/users";

  if (userId) {
    url += `/${userId}`;
  }

  fetch(url).then(async (response) => {
    const data = await response.json();

    if (options.pretty) {
      return console.log(data);
    }

    return console.log(JSON.stringify(data));
  });
};

Esto debería parecer muy familiar. Aquí, nuestra función para users es idéntico a posts , la única diferencia es el /users al final de nuestro url a diferencia de /posts .

¡Eso es todo! Antes de terminar, vamos a aprender cómo instalar nuestra CLI globalmente en nuestra máquina para que podamos usar nuestro jsonp comando en lugar de tener que ejecutar cosas con node index.js ... como vimos arriba.

Instalación global de su CLI para pruebas

Afortunadamente, instalar nuestro paquete globalmente en nuestra máquina es muy simple. Recuerde que anteriormente, agregamos un campo bin a nuestro /package.json expediente. Cuando instalamos nuestro paquete (o un usuario lo instala una vez que lo hemos publicado en NPM u otro repositorio de paquetes), NPM tomará la propiedad que configuramos en este objeto y la agregará a la variable PATH en nuestra computadora (o la de nuestros usuarios). . Una vez instalado, podemos usar este nombre; en este tutorial, elegimos jsonp para el nombre de nuestro comando—en nuestra consola.

Para instalar nuestro paquete, asegúrese de ser cd 'd en la raíz de la carpeta del proyecto (donde nuestro index.js se encuentra el archivo) y luego ejecute:

Terminal

npm i -g .

Aquí, decimos "NPM, instale el paquete ubicado en el directorio actual . globalmente en nuestra computadora". Una vez que ejecute esto, NPM instalará el paquete. Después de eso, debería tener acceso a un nuevo comando en su consola, jsonp :

Terminal

jsonp posts -p

Debería ver la salida que configuramos anteriormente en la consola:

Terminando

En este tutorial, aprendimos a crear una interfaz de línea de comandos (CLI) con Node.js y Commander.js. Aprendimos a configurar un proyecto barebones de Node.js, modificando el package.json archivo para incluir un "type": "module" campo para habilitar los módulos de JavaScript, así como un bin campo para especificar un comando para agregar al PATH variable en nuestra computadora cuando nuestro paquete está instalado.

También aprendimos cómo usar una línea shebang para decirle a nuestra consola cómo interpretar nuestro código y cómo usar Commander.js para definir comandos y apuntar a funciones que aceptan argumentos y opciones. Finalmente, aprendimos cómo instalar globalmente nuestra herramienta de línea de comandos para poder acceder a ella a través del nombre que le proporcionamos a nuestro bin configuración en nuestro package.json archivo.