Почему HagiCode выбрал execa для выполнения CLI-команд
Почему HagiCode выбрал execa для выполнения CLI-команд
В проектах Node.js прямое использование child_process для выполнения внешних команд имеет такие проблемы, как большие различия между платформами и несогласованная обработка ошибок. В этой статье мы делимся практическим опытом внедрения execa в проект HagiCode, включая ключевые проектные решения и реальные примеры кода.
Предпосылки
В проектах Node.js прямое использование модуля child_process для выполнения внешних команд — распространённая практика, но у этого подхода довольно много проблем:
- Большие различия между платформами: файлы
.cmd/.batв Windows требуют специальной обработки, пути с пробелами нужно заключать в кавычки - Несогласованная обработка ошибок: форматы сообщений об ошибках
execFile,spawn,execFileSyncразличаются, их сложно обрабатывать единообразно - Сложная обработка потоков: требуется вручную обрабатывать сбор и буферизацию потоков stdout/stderr
- Сложная обработка тайм-аутов и сигналов: требуется дополнительный код для отмены команд по тайм-ауту и обработки сигналов процесса
Подпроекты Hagiscript и Desktop проекта HagiCode должны выполнять большое количество внешних CLI-команд (npm, node, PowerShell и т. д.), прямое использование child_process привело к дублированию кода и высоким затратам на сопровождение.
Для решения этих проблем мы приняли решение: внедрить execa как единое решение для выполнения команд. Изменения, принесшие это решение, на самом деле больше, чем вы можете представить — об этом я расскажу подробнее ниже.
О HagiCode
Решения, описанные в этой статье, основаны на нашем практическом опыте в проекте HagiCode. HagiCode — это проект AI-помощника по программированию, который должен выполнять большое количество внешних команд в нескольких подпроектах (движок сценариев Hagiscript и десктопное приложение Desktop). Эта сложность многоязычности и мультиплатформенности, возможно, и есть прямая причина нашего внедрения execa.
Если вы считаете, что решения, описанные в этой статье, ценны, это означает, что наши инженерные навыки неплохи — тогда и сам HagiCode заслуживает внимания.
Почему был выбран execa?
execa — это зрелая библиотека выполнения процессов, решающая основные проблемы child_process:
- Кроссплатформенная согласованность: автоматически обрабатывает командные прокси-файлы Windows, не требует ручного определения файлов
.cmd - Единообразная обработка ошибок: стандартизированные объекты ошибок, включающие exitCode, signal, timedOut, stdout, stderr
- Лучший дизайн API: поддержка Promise API, отмены AbortSignal, обработки потоков
- Безопасность: сохранение границ параметров, избежание риска инъекции команд
Именно эти характеристики нам были нужны в процессе разработки HagiCode. Hagiscript должен выполнять команды npm на разных платформах, Desktop должен вызывать PowerShell и различные инструменты разработки, кроссплатформенная согласованность execa значительно сократила наш код адаптации платформ. Ведь кто хочет писать специальный код обработки для каждой платформы?
Ключевые проектные решения
В обоих проектах используется уровень внутренней инкапсуляции, а не прямой вызов execa:
// Единый исполнитель Hagiscriptexport const runCommand: CommandRunner = async (command, args, options) => { const result = await execa(command, args, { /* normalized options */ }); return { /* normalized result */ };};Причины:
- сохранение специфических для домена типов ошибок (например,
NpmCommandError) - удобство внедрения имитации исполнителя при тестировании
- унифицированная обработка ошибок и логирование
- в будущем можно легко заменить базовую реализацию
Защита границ параметров
В обеих реализациях подчёркивается массив параметров, а не строка shell:
// Правильно: границы параметров чёткиеawait runCommand('npm', ['install', '@scope/package@1.0.0']);
// Ошибка: риск инъекцииawait execa(`npm install @scope/package@1.0.0`, { shell: true });Это избегает проблем безопасности с цитированием, экранированием и инъекцией параметров. В HagiCode нам часто приходится обрабатывать параметры, введённые пользователем, такие как имена пакетов, имена сценариев и т. д., использование массивов параметров может эффективно предотвратить инъекцию команд. Ведь безопасность — это то, что一旦 становится проблемой, это уже большая проблема.
Решение Hagiscript
Подпроект Hagiscript проекта HagiCode создал модуль runtime/command-launch.ts, предоставляющий:
- Единый исполнитель: функция
runCommandинкапсулирует execa - Стандартизированный результат: интерфейс
CommandResult - Стандартизированная ошибка: класс
CommandExecutionError - Вспомогательные функции совместимости:
normalizeCommandPath,requiresShellLaunch
export interface CommandResult { command: string; args: string[]; stdout: string; stderr: string; exitCode?: number; signal?: string; timedOut?: boolean;}
export class CommandExecutionError extends Error { readonly context: CommandFailureContext;}Этот набор абстракций позволяет Hagiscript единообразно обрабатывать все внешние команды, будь то установка зависимостей через npm или выполнение сценариев через node. Ну, с единым интерфейсом писать код действительно гораздо удобнее.
Решение Desktop
Подпроект Desktop проекта HagiCode создал модуль utils/cli-executor.ts, предоставляющий:
- Параметры выполнения:
CliExecutorOptionsподдерживает тайм-аут, отмену, переменные окружения - Классификация результатов:
CliExecutionResultсодержит состояние успеха/неудачи - Обработка потоков:
executeCliStreamingподдерживает обратный вызов вывода в реальном времени - Классификация ошибок:
CliFailureKindразличает типы отказов: выход, тайм-аут, отмена и т. д.
export async function executeCli(options: CliExecutorOptions): Promise<CliExecutionResult>export async function executeCliStreaming(options: CliExecutorOptions): Promise<CliExecutionResult>Desktop должен отображать прогресс выполнения команд в UI, функция потоковой обработки оказалась очень кстати. Пользователи могут видеть вывод npm install в реальном времени, а не ждать завершения выполнения команды, чтобы увидеть результат. Этот опыт, ну, как бы это сказать, после использования уже не вернёшься.
Примеры использования
Выполнение команд в Hagiscript
import { runCommand } from '../runtime/command-launch.js';
// Простое выполнениеconst result = await runCommand('node', ['--version']);console.log(result.stdout); // 'v20.0.0'
// Выполнение с опциямиconst installResult = await runCommand('npm', ['install', 'express'], { cwd: '/project/path', env: { NODE_ENV: 'development' }, timeoutMs: 30000});Выполнение команд в Desktop
import { executeCli, executeCliStreaming } from './utils/cli-executor.js';
// Буферизованное выполнениеconst result = await executeCli({ command: 'npm', args: ['list', '--json'], cwd: projectPath, timeoutMs: 5000,});
if (result.success) { console.log(result.stdout);} else { console.error(result.error?.message);}
// Потоковое выполнениеawait executeCliStreaming({ command: 'npm', args: ['install'], onOutput: (type, data) => { console.log(`[${type}]`, data); }});Обработка ошибок
try { await runCommand('npm', ['install', 'invalid-package']);} catch (error) { if (error instanceof CommandExecutionError) { console.error('Command failed:', error.context.command); console.error('Exit code:', error.context.exitCode); console.error('Stderr:', error.context.stderr); }}Единообразная обработка ошибок позволяет нам предоставить лучший пользовательский опыт в HagiCode. Например, при сбое установки npm мы можем извлечь конкретную информацию об ошибке и показать её пользователю, а не отображать общее “не удалось выполнить команду”. Ведь, когда пользователь видит конкретную информацию об ошибке, он хотя бы знает, где проблема.
Стратегия тестирования
Оба проекта поддерживают внедрение зависимостей, что удобно для тестирования:
// Производственный кодasync function installPackage(pkg: string, runCommand = defaultRunCommand) { return runCommand('npm', ['install', pkg]);}
// Тестовый кодit('installs package', async () => { const mockRunCommand = vi.fn().mockResolvedValue({ stdout: 'installed', stderr: '', exitCode: 0 }); await installPackage('test-pkg', mockRunCommand); expect(mockRunCommand).toHaveBeenCalledWith('npm', ['install', 'test-pkg']);});Этот дизайн делает тестирование HagiCode более надёжным и быстрым. Нам не нужно действительно выполнять команды npm в тестах, достаточно имитировать исполнителя, возвращающего ожидаемый результат. Тесты выполняются быстро, и настроение разработки, естественно, улучшается.
Меры предосторожности
В практике HagiCode мы обобщили следующие меры предосторожности:
- Сохраняйте разделение параметров: всегда передавайте команду и параметры как отдельные элементы массива
- Осторожно используйте режим shell: используйте
shell: trueтолько при необходимости, например, когда нужны каналы или перенаправление - Обрабатывайте тайм-ауты: устанавливайте
timeoutMsдля команд, которые могут зависнуть - Размер Buffer: при большом выводе рассмотрите установку
maxBuffer - Пути в Windows: execa автоматически обрабатывает прокси-файлы
.cmd, ручное определение не требуется - Отмена операций: используйте
AbortSignal, а не ручнойkill() - Классификация ошибок: различайте сценарии сбоя запуска процесса, сбоя выполнения, тайм-аута, отмены и т. д.
Это всё ловушки, на которые мы наступили в реальной разработке, возможно, это поможет вам меньше идти окольными путями.
Распространённые ловушки
// Ошибка: конкатенация строк может привести к инъекцииawait execa(`npm install ${userInput}`, { shell: true });
// Правильно: массив параметровawait execa('npm', ['install', userInput]);
// Ошибка: игнорирование тайм-аутаawait execa('npm', ['install', 'heavy-package']);
// Правильно: установка тайм-аутаawait execa('npm', ['install', 'heavy-package'], { timeout: 60000 });
// Ошибка: предположение, что код выхода равен 0const result = await execa('npm', ['install']);
// Правильно: проверка на неудачуtry { await execa('npm', ['install']);} catch (error) { // Обработка сбоя}Об этих ловушках, если говорить, это слёзы. Ведь кто не наступал на несколько ловушек в производственной среде?
Заключение
После внедрения execa качество кода и сопровождаемость проекта HagiCode в части выполнения команд значительно улучшились:
- Кроссплатформенная согласованность: больше не нужно писать специальный код обработки для Windows
- Единообразная обработка ошибок: структурированная информация об ошибках, удобная для отображения и анализа
- Лучшая тестируемость: через внедрение зависимостей можно легко имитировать выполнение команд
- Более безопасная обработка параметров: использование массивов параметров избегает риска инъекции
Если вам также нужно выполнять внешние команды в проектах Node.js, настоятельно рекомендую попробовать execa. Решения, описанные в этой статье, были получены нами в процессе разработки HagiCode на основе реальных проблем и реальной оптимизации, надеюсь, это будет вам полезно.
В конце концов, хорошие инструменты заслуживают того, чтобы о них знало больше людей…
Справочные материалы
- Официальная документация execa
- Документация Node.js child_process
- Репозиторий HagiCode на GitHub
- Официальный сайт HagiCode
Если эта статья вам помогла:
- Приходите на GitHub и поставьте звёздочку: github.com/HagiCode-org/site
- Посетите официальный сайт, чтобы узнать больше: hagicode.com
- Посмотрите 30-минутную демонстрацию: www.bilibili.com/video/BV1pirZBuEzq/
- Установка в один клик: docs.hagicode.com/installation/docker-compose
- Быстрая установка десктопной версии Desktop: hagicode.com/desktop/
开始使用 HagiCode
一次安装,几分钟上手
HagiCode for Windows 在 Microsoft Store 免费提供。打开商店即可安装并保持更新;也可以先对比各版本与定价,再决定从哪个渠道开始。