Раздел 46 · Effect: Тихая Академия

Почтамт из терминала: effect/cli

middle~25 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Командная строка это тоже окошко, только без вывески. В уроке 91 Почтамт сначала записал договор окошек, а сервер, клиент и документация вышли из него сами. У терминала договор такой же: какие слова команда принимает, какие из них обязательны, что значит каждое и что отвечать, когда слово не то. Если договор записан значением, из него выходят и разбор строки, и справка, и сообщение об ошибке. Для этого в Effect есть effect/cli.

Урок короткий, задача у него одна, разминка. Новых сервисов не будет: всё, что нужно команде, уже было в уроке 95.

Из терминала приходят только строки

Программа получает из терминала массив строк. Никаких типов, никаких флагов: ['send', 'T-2', 'буря к ночи', '--from', 'Липвиг']. Что из этого адрес, что текст, а что имя отправителя, программа решает сама. Решать можно руками, и о том, чем это кончается, расскажет Ринцвальд ниже. А можно описать слова команды значениями и отдать разбор библиотеке.

В effect/cli у слов два вида. Аргумент узнают по месту: первое слово после send это адрес, второе текст. Флаг узнают по имени: --from Липвиг можно поставить в начало, в конец, в середину.

import { Console } from 'effect';
import { Argument, Command, Flag } from 'effect/cli';

const to = Argument.String('to').pipe(Argument.withDescription('башня адреса, например T-2'));
const from = Flag.String('from').pipe(
  Flag.withAlias('f'),
  Flag.withDescription('кто пишет'),
  Flag.withDefault('горожанин'),
);
const urgent = Flag.Boolean('urgent').pipe(Flag.withDefault(false));

export const send = Command.make(
  'send',
  { to, from, urgent, text: Argument.String('text') },
  ({ to, from, urgent, text }) =>
    Console.log(`${urgent ? 'срочно: ' : ''}от ${from} для ${to}: ${text}`),
);

Разберём по строкам.

  • Argument.String('to') это описание, а не значение: «здесь будет строка по имени to». Как схема из урока 77, оно ничего не читает, пока его не применят.
  • Flag.withAlias('f') даёт флагу короткое имя: -f Липвиг то же, что --from Липвиг. Flag.withDefault('горожанин') делает флаг необязательным.
  • Flag.Boolean('urgent') это флаг без значения: есть --urgent в строке, значит true.
  • Command.make('send', { ... }, обработчик) собирает команду. Второй аргумент это объект описаний, и обработчик получает объект с теми же ключами, уже разобранный и с типами: to: string, urgent: boolean. Ключ text в объекте и ключ text в обработчике связаны типом, опечатку поймает компилятор.
  • Обработчик возвращает эффект. Печатает он через Console.log из Effect, а не через глобальный console: так вывод можно подменить в тесте.

Порядок ключей в объекте задаёт порядок аргументов в строке: to объявлен раньше text, значит в строке он идёт первым.

Проверка на входе

Ночной смотритель напечатал T2. Для Argument.String это прекрасная строка, и письмо уйдёт в никуда. Проверка внутри обработчика это снова «проверяй», а не «разбирай», и урок «Разбирай, а не проверяй» был про это. Лучше дать аргументу схему:

const TowerCode = Schema.String.check(
  Schema.isPattern(/^T-\d+$/, { message: 'код башни вида T-число' }),
);
const to = Argument.String('to').pipe(Argument.withSchema(TowerCode));

Та же Schema, что разбирала кадры Магистрали, теперь стоит на границе с терминалом. Обработчик не позовут, если адрес не прошёл, а смотритель увидит:

ERROR
  Invalid value for argument <to>: "T2". Expected: Schema validation failed: код башни вида T-число

Рамка ошибки английская, её пишет библиотека; последние слова наши, из message. Схема с брендом из урока 78 тоже подойдёт: обработчик получит уже TowerCode, а не голую строку.

Справка из договора

Команда из одного send это ещё не программа. Корень pochtamt держит подкоманды, а запуск отдаёт ему строку целиком:

export const pochtamt = Command.make('pochtamt').pipe(
  Command.withDescription('Почтамт Анкмора из терминала'),
  Command.withSubcommands([send]),
);

export const run = Command.runWith(pochtamt, { version: '1.0.0' });

Command.runWith(pochtamt, { version }) это функция от массива строк к эффекту. Она разбирает строку, выбирает подкоманду, проверяет аргументы схемами и зовёт обработчик. А ещё она умеет то, о чём мы её не просили: --help, --version, --completions, --wizard и --log-level. Справка собрана из тех же описаний, что и разбор:

DESCRIPTION
  Принять письмо

USAGE
  pochtamt send [flags] <to> <text>

ARGUMENTS
  to string      башня адреса, например T-2
  text string    текст письма

FLAGS
  --from, -f string    кто пишет
  --urgent             срочное письмо

Ниже идут общие флаги (--help, --version, --completions и другие), их мы обрезали. Справку нельзя забыть обновить: её никто не пишет. Добавил флаг в объект описаний, и он появился и в разборе, и в справке. Как OpenAPI в уроке 92, только для терминала.

В настоящей программе строку берут из процесса, а не из массива. Для этого есть Command.run, который читает аргументы из сервиса Stdio:

import { NodeRuntime, NodeServices } from '@effect/platform-node';

pochtamt.pipe(Command.run({ version: '1.0.0' }), Effect.provide(NodeServices.layer), NodeRuntime.runMain);

NodeServices.layer даёт настоящие файлы, терминал, процессы и аргументы Node. На Bun будет BunServices, как с платформой в уроке 95: команда одна, слой разный.

Нестабильно. Модули effect/cli и effect/process в 4.1 помечены @stability unstable: имена Argument, Flag, Command и текст справки могут поменяться в минорной версии. Урок написан на 4.1.0.

Команда в тесте

Командой из терминала пользуются в три часа ночи. Проверять её хочется днём и без терминала. Запуск через runWith требует сервисов: файлов, путей, терминала, процессов и Stdio. Зачем команде, которая печатает строку, процессы и файлы? Затем, что общие флаги умеют больше: --completions печатает скрипт для оболочки, мастер --wizard спрашивает значения в терминале. Библиотека не знает, какие флаги ты наберёшь, поэтому просит всё.

В тесте всё это подменяется заглушками: пути настоящие, ввод пустой, а файлы, терминал и процессы падают, если их тронут:

const Cli = Layer.mergeAll(
  FileSystem.layerNoop({}),
  Path.layer,
  Stdio.layerTest({}),
  Layer.succeed(
    Terminal.Terminal,
    Terminal.make({
      columns: Effect.succeed(80),
      rows: Effect.succeed(24),
      size: Effect.die('нет терминала'),
      readInput: Effect.die('нет терминала'),
      readLine: Effect.die('нет терминала'),
      display: () => Effect.void,
    }),
  ),
  Layer.succeed(
    ChildProcessSpawner.ChildProcessSpawner,
    ChildProcessSpawner.make(() => Effect.die('нет процессов')),
  ),
);

const check = Effect.gen(function* () {
  const exit = yield* Effect.exit(run(['send', 'T-2', 'буря к ночи', '-f', 'Липвиг']));
  return { ok: Exit.isSuccess(exit), lines: yield* TestConsole.logLines };
}).pipe(Effect.provide(Layer.mergeAll(Cli, TestConsole.layer)));
// { ok: true, lines: ['от Липвиг для T-2: буря к ночи'] }

TestConsole из effect/testing записывает всё, что напечатал Console.log, а TestConsole.errorLines всё, что ушло в вывод ошибок: туда пишется и ERROR про T2. it.effect из @effect/vitest даёт TestConsole каждому тесту сам. Одна тонкость: строки копятся за весь тест. Два запуска команды в одном тесте читают общий вывод, поэтому каждый случай лучше проверять своим тестом.

Где тут дыра

const [, , command, to, text] = process.argv;
const from = process.argv[process.argv.indexOf('--from') + 1];
if (command === 'send') console.log(`от ${from} для ${to}: ${text}`);

На его примере всё работает: send T-2 привет --from Липвиг печатает от Липвиг для T-2: привет. Флаг стоит в конце, позиции адреса и текста не сдвинулись. Теперь ночной смотритель ставит короткий флаг вперёд, send -f Тикка T-3 тик, и получает от /usr/bin/node для -f: Тикка. Адресом стал -f, текстом имя, а отправителем путь к Node: --from не нашёлся, indexOf вернул -1, и -1 + 1 это ноль, первая строка argv. Без флага вовсе, send T-3 тик, отправителем снова путь к Node. Справки нет, проверки адреса нет, и программа ни разу не сказала, что что-то не так. Разбор руками держит одну раскладку строки, ту, на которой его писали.

Разминка

Допиши команду pochtamt send целиком: аргументы с проверкой адреса, флаги с короткими именами и значениями по умолчанию, описания для справки.

Практика

Дальше

К утру ночная смена приняла из терминала сорок писем, и ни одно не ушло на T2. Рыдкулл читает журнал смены и сворачивает его в трубку.

Заказ ждёт в следующем уроке.