Командная строка это тоже окошко, только без вывески. В уроке 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. Рыдкулл читает журнал смены и сворачивает его в трубку.
Заказ ждёт в следующем уроке.