Командная строка - это пользовательский интерфейс, которому в мире разработки JavaScript не уделяется должного внимания. Реальность такова, что у большинства инструментов разработчика должен быть интерфейс командной строки, который будет использоваться такими ботаниками, как мы, и пользовательский интерфейс должен быть на одном уровне с вашим тщательно созданным веб-приложением. Это включает в себя красивый дизайн, полезные меню, чистые сообщения об ошибках и выходные данные, индикаторы загрузки и индикаторы выполнения и так далее.

Когда дело доходит до создания интерфейсов командной строки с помощью Node, существует не так много реальных руководств, поэтому это первая серия из серии, которая выйдет за рамки простого CLI-приложения «hello world». Мы создадим приложение под названием outside-cli, которое предоставит вам текущую погоду и 10-дневный прогноз для любого места.

Примечание: существует несколько библиотек, которые помогают в создании сложных интерфейсов командной строки, таких как oclif, yargs и commander, но мы будем сокращать наши зависимости для этого примера. чтобы вы могли лучше понять, как все работает под капотом. В этом руководстве предполагается, что у вас есть базовые знания JavaScript и Node.

Начиная

Как и во всех проектах JavaScript, создание package.json и файла записи - лучший способ начать работу. Мы можем сделать это просто - пока никаких зависимостей не требуется.

package.json

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

index.js

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

Нам понадобится способ вызвать наше недавно созданное приложение и показать приветственное сообщение, а также добавить его в системный путь, чтобы его можно было вызывать из любого места. Файл bin - это способ сделать это.

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

Никогда раньше не видел #!/usr/bin/env node? Это называется шебанг. По сути, он сообщает системе, что это не сценарий оболочки, и он должен использовать другой интерпретатор.

Важно, чтобы двоичный файл был небольшим, так как его единственная цель - вызвать приложение. Весь наш код должен жить вне двоичного кода, чтобы он оставался модульным и тестируемым. Это также поможет, если мы захотим предоставить программный доступ к нашей библиотеке в будущем.

Чтобы запустить bin-файл напрямую, нам нужно предоставить ему правильные права доступа к файловой системе. Если вы работаете в UNIX, это так же просто, как запустить chmod +x bin/outside. Если вы работаете в Windows, сделайте себе одолжение и используйте подсистему Linux.

Затем мы добавим наш двоичный файл в файл package.json. Это автоматически поместит его в системный путь пользователя, когда они установят наш пакет как глобальный (npm install -g outside-cli).

package.json

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

Теперь мы можем вызвать наш bin-файл напрямую, запустив ./bin/outside. Вы должны увидеть приветственное сообщение. Запуск npm link в корне вашего проекта символически привяжет ваш двоичный файл к системному пути, что сделает его доступным из любого места, запустив outside.

Когда вы запускаете приложение CLI, оно состоит из аргументов и команд. Аргументы (или «флаги») - это значения с одним или двумя дефисами (например, -d, --debug или --env production), которые полезны для передачи параметров нашему приложению. Команды - это все другие значения, у которых нет флага.

В отличие от команд, аргументы не нужно указывать в каком-либо определенном порядке. Например, мы могли бы запустить outside today Brooklyn и просто предположить, что вторая команда всегда будет местоположением - но не лучше ли запустить outside today --location Brooklyn, если мы захотим добавить больше параметров в будущем?

Чтобы наше приложение было вообще полезным, нам нужно проанализировать эти команды и аргументы и превратить их в объект. Мы всегда можем перейти к process.argv и попробовать сделать это сами, но давайте установим нашу первую зависимость под названием minimist, чтобы позаботиться об этом за нас.

npm install --save minimist

index.js

const minimist = require('minimist')

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

Примечание. Причина, по которой мы удаляем первые два аргумента с .slice(2), заключается в том, что первым аргументом всегда будет интерпретатор, за которым следует имя интерпретируемого файла. Нас интересуют только аргументы после этого.

Теперь запуск outside today должен выводить { _: ['today'] }. Если вы запустите outside today --location "Brooklyn, NY", он должен вывести { _: ['today'], location: 'Brooklyn, NY' }. Мы более подробно рассмотрим аргументы позже, когда мы действительно будем использовать местоположение, но пока этого достаточно, чтобы настроить нашу первую команду.

Синтаксис аргумента

Чтобы лучше понять, как работает синтаксис аргументов, вы можете прочитать это. В принципе, флаг может быть разделен одинарным или двойным дефисом и будет принимать значение, следующее сразу за командой, или будет равно true, если значение отсутствует. Флаги с одним дефисом также можно комбинировать для коротких логических значений (-a -b -c или -abc даст вам { a: true, b: true, c: true }.)

Важно помнить, что значения должны быть заключены в кавычки, если они содержат специальные символы или пробел. Запуск --foo bar baz даст вам `{: ['baz'], foo: 'bar'} _29 _-- foo 'bar baz'would give you {foo:' bar baz '}` ._

Рекомендуется разделить код для каждой команды и загружать его в память только при вызове. Это сокращает время запуска и предотвращает загрузку ненужных модулей. Достаточно просто с помощью оператора switch в главной команде, данной нам минимистом. Используя эту настройку, каждый командный файл должен экспортировать функцию, и в этом случае мы передаем аргументы каждой команде, чтобы мы могли использовать их позже.

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')
}

Теперь, если вы запустите outside today, вы увидите сообщение «сегодня солнечно», а если вы запустите outside foobar, оно сообщит вам, что «foobar» не является допустимой командой. Нам все еще нужно запросить погодный API, чтобы получить реальные данные, но это хорошее начало.

Есть несколько команд и аргументов, которые должны присутствовать в каждом интерфейсе командной строки: help, --help и -h, которые должны отображать меню справки, и version, --version и -v, которые должны выводить текущую версию приложения. Мы также должны по умолчанию использовать главное меню справки, если команда не указана.

Это можно легко реализовать в нашей текущей настройке, добавив два случая к нашему оператору switch, значение по умолчанию для переменной cmd и реализовав некоторые операторы if для флагов аргументов справки и версии. Minimist автоматически анализирует аргументы на пары "ключ-значение", поэтому запуск outside --version сделает args.version равным 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
  }
}

Чтобы реализовать наши новые команды, используйте тот же формат, что и команда 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)
}

Теперь, если вы запустите outside help today или outside today -h, вы должны увидеть меню справки для команды today. Запуск outside или outside -h должен показать вам главное меню справки.

Эта настройка проекта действительно потрясающая, потому что если вам нужно добавить новую команду, все, что вам нужно сделать, это создать новый файл в папке cmds, добавить его в оператор switch и добавить меню справки, если оно есть.

cmds / прогноз.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`,
}

// ...

Иногда выполнение команды может занять много времени. Если вы извлекаете данные из API, генерируете контент, записываете файлы на диск или любой другой процесс, который занимает более нескольких миллисекунд, вы хотите дать пользователю некоторую обратную связь, что ваше приложение не зависло и просто работает. жесткий. Иногда вы можете измерить прогресс вашей операции, и имеет смысл показать индикатор выполнения, но в других случаях он более изменчив и имеет смысл вместо этого показывать индикатор загрузки.

В нашем приложении мы не можем измерять ход выполнения запросов к API, поэтому мы будем использовать простой счетчик, чтобы показать, что что-то происходит. Установите еще две зависимости для наших сетевых запросов и нашего счетчика:

npm install --save axios ora

Получение данных из API

Теперь давайте создадим утилиту, которая будет запрашивать у Yahoo Weather API текущие условия и прогноз местоположения.

Примечание. Yahoo API использует синтаксис YQL, и это немного странно - не пытайтесь понять его, просто скопируйте и вставьте. Это был единственный API погоды, который не требовал ключа 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)
  }
}

Теперь, если вы запустите outside today --location "Brooklyn, NY", вы увидите быстрый счетчик, пока он делает запрос, а затем текущие погодные условия.

Поскольку запрос выполняется так быстро, индикатор загрузки может быть трудно увидеть. Если вы хотите вручную замедлить его, чтобы увидеть его, вы можете добавить эту строку в начало своей служебной функции погоды: await new Promise(resolve => setTimeout(resolve, 5000)).

Большой! Теперь давайте скопируем этот код в нашу команду forecast и немного изменим форматирование.

cmds / прогноз.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)
  }
}

Теперь вы можете увидеть прогноз погоды на 10 дней при запуске outside forecast --location "Brooklyn, NY". Выглядит неплохо! Давайте добавим еще одну утилиту для автоматического определения нашего местоположения на основе нашего IP-адреса, если в команде не указано местоположение.

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 / прогноз.js

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

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

Теперь, если вы просто запустите outside forecast без указания местоположения, вы увидите прогноз для своего текущего местоположения.

Обработка ошибок

Я не вдавался в подробности о том, как лучше всего обрабатывать ошибки (это будет в следующем руководстве), но самое важное, что нужно помнить, - это использовать правильные коды выхода.

Если в вашем интерфейсе командной строки возникает критическая ошибка, вы должны выйти с process.exit(1). Это позволяет терминалу знать, что программа не завершилась чисто, что, например, уведомит вас из службы CI.

Давайте создадим быструю утилиту, которая сделает это за нас, чтобы мы могли получить правильный код выхода при запуске несуществующей команды.

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
  // ...
}

Заканчивать

Последний шаг к распространению нашей библиотеки - публикация ее в диспетчере пакетов. Поскольку наше приложение написано на JavaScript, имеет смысл опубликовать его в NPM. Давайте еще немного заполним наш package.json:

{
  "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"
  }
}
  • Установка engine гарантирует, что у всех, кто устанавливает наше приложение, будет обновленная версия Node. Поскольку мы используем синтаксис async / await без транспиляции, нам требуется Node 8.0 или выше.
  • Установка preferGlobal предупредит пользователя об установке с npm install --save, а не с npm install --global.

Вот и все! Теперь вы можете запустить npm publish, и ваше приложение будет доступно для загрузки. Если вы хотите сделать еще один шаг и выпустить в других менеджерах пакетов (таких как Homebrew), вы можете проверить pkg или nexe, которые помогут вам объединить ваше приложение в автономный двоичный файл.

Резюме

Это структура, которой мы придерживаемся для всех наших приложений CLI здесь, в Timber, и она помогает поддерживать порядок и модульность.

Некоторые ключевые выводы из этого руководства для тех, кто только бегло просмотрел его:

  • Файлы bin являются точкой входа для любого приложения CLI и должны вызывать только основную функцию
  • Командные файлы не должны требоваться, пока они не понадобятся
  • Всегда включайте команды help и version
  • Сохраняйте компактность командных файлов - их основная цель - вызывать функции и показывать сообщения пользователя.
  • Всегда показывать какой-то индикатор активности
  • Выйдите с правильными кодами ошибок

Надеюсь, теперь вы лучше понимаете, как создавать и организовывать приложения CLI в Node. Это первая часть серии руководств, поэтому вернемся к ней позже, когда мы более подробно рассмотрим добавление дизайна, изображения и цвета ascii, принятие пользовательского ввода, написание интеграционных тестов и многое другое. Вы можете увидеть весь исходный код, который мы написали сегодня, на GitHub.

Мы - это облачная лесозаготовительная компания, @ Timber. Мы были бы рады, если бы вы попробовали наш продукт (это действительно здорово! - вы можете создать бесплатную учетную запись здесь), но это все, что мы собираемся рекламировать наш продукт ... вы, ребята, пришли сюда, чтобы узнать о создании интерфейса командной строки. App в Node, и, надеюсь, это руководство помогло вам начать работу.

Изначально опубликовано на timber.io.