Делаем удобный таск-раннер и публикуем его как npm-пакет

An illustration of a person holding a toy spaceship in one hand and a toy brick in the other, with a playful and focused expression, possibly building or playing. An illustration of a person holding a toy spaceship in one hand and a toy brick in the other, with a playful and focused expression, possibly building or playing.

Рано или поздно при работе с тем или иным языком программирования возникает желание написать на нем свой вспомогательный инструмент для разработки. И впрямь, трудно представить современный мир программирования, а тем более мир javascript, на котором мне довелось писать последнее время, без огромного количества библиотек/фреймворков/консольных утилит.

Подходящая для этого ситуация недавно сложилась в одном из моих проектов по работе. Все мы знаем, что нам очень часто приходится запускать различные скрипты/программы, которые требуют тех или иных переменных окружения или просто параметров командной строки. Для этого в проекте использовался better-npm-run и все бы было хорошо и не требовало каких-то альтернатив, если бы не одно "но": в большом проекте скопилось огромное количество однотипных тасков, единственное отличие которых между собой заключалось лишь в одной из переменных окружения, что отражалось в соответствующей части названия, по которому происходит запуск таска, к тому же это приводило к необъятному размеру package.json, в котором хранились десятки конфигураций запускаемых команд. Поясню:

    {
        // ...
        "betterScripts": {
            "build:main": {
                "command": "webpack --config --progress --colors",
                "env": {
                    "CHUNK_NAME": "main"
                }
            },
            "build:contents": {
                "command": "webpack --config --progress --colors",
                "env": {
                    "CHUNK_NAME": "contents"
                }
            },
            "build:info": {
                "command": "webpack --config --progress --colors",
                "env": {
                    "CHUNK_NAME": "info"
                }
            }
            // ...
        }
    }

Запуск этих команд производился согласно документации с помощью вызова в консоли скажем bnr build:main. В данном случае main - это одна из частей приложения, которых на самом деле много.

Постановка задачи

Пелену конфигов нужно вынести в отдельный файл scripts.json, сократив до шаблонов следующего вида: javascript [ { "name": "build:<%name%>", "command": "webpack --config --progress --colors", "env": { "CHUNK_NAME": "<%name%>" } }, // ... ]

Оформление конфига в виде массива нужно по причине отсутствия, необходимого для подбора шаблона имени, приоритета при аналогичной реализации в виде объекта так как, напомню, ключи в js объекте неупорядочены. Это кстати важный момент: очень часто начинающие программисты ожидают, что перебор ключей объекта будет происходить в том же порядке, в котором они определены в коде. Запуск должен производиться похожим образом prun build:name.

Таким образом, нам нужно решить в коде следующие задачи:

  1. Загрузка конфигурации
  2. Создание ряда регулярных выражений
  3. Поиск первого подходящего к имени
  4. Выполнение таска, ассоциированного с ним

Автор этой статьи много знает, потому что учился у менторов mkdev Выбрать ментора

Довольно слов - покажите нам код

Главный файл:

    #!/usr/bin/env node

    var join = require('path').join;
    var fullScriptsPath = join(process.cwd(), 'scripts.json');
    var Pattern = require('./lib/Pattern'); // Здесь инкапсулирована логика соответствия конкретного шаблона

    var run = require('./lib/run'); // Логика по запуску необходимого таска с определенной конфигурацией 

    var err = require('./lib/logger').err; // Различные вспомогательные функции
    var exit = require('./lib/logger').exit; //
    var log = require('./lib/logger').log; //
    var m = require('./lib/logger').modifiers; //

    var commandName = process.argv[2];

    try {
        var scripts = require(fullScriptsPath);
    } catch(e) {
        exit(e.toString()); // Если при открытии файла конфигурации что-то пошло не так
    }

    !commandName && exit('No script name presented!');

    for(var i = 0; i < scripts.length; i++) { // Перебор по всем таскам с поиском первого подходящего
        var pattern = new Pattern(scripts[i]);

        if (pattern.matches(commandName)) {
            log('Starting script: ');
            log(commandName + '\n', m.FgRed);
            run(pattern.getDataForExecution(), function (code) {
                code && exit('script exit code: ' + code);
                log('Finishing script: ');
                log(commandName + '\n', m.FgRed);
            });
            break;
        }

        if (i + 1 === scripts.length) { // Ну и если подходящего таска так и не найдено
            err('No pattern for such script: ');
            exit(commandName + '\n', m.FgRed);
        }
    }

Вспомогательный класс для поиска подходящего таска по имени - в нашем случае он должен предоставлять следующий интерфейс:

  1. Конструктор
  2. функция matches(commandName) - подходит ли данный паттерн под название запущенного таска
  3. функция getDataForExecution() - предоставление конкретных данных, с подставленными значениями параметров
    function build(name) { 
        // Вспомогательная функция для создания регулярного выражения для сопоставляющегося с именем таска,
        // а также массива имен параметров в том порядке, в котором они идут в шаблоне
        var re = /<%\w+%>/, match, data = [];
        while(match = re.exec(name)) {
            name = name.replace(match[0], '(\\w+)');
            data.push(match[0].slice(2, -2)) // убираем из найденного <% и %> и сохраняем название параметра
        }
        return {
            regexp: new RegExp('^' + name + '$'), // создаем регулярное выражение из строки добавив якоря начала и конца
            paramNames: data
        }
    }

    function TemplateEngine(tpl, data) {
        // Замена имен параметров в строке на их значения 
        var re = /<%([^%>]+)?%>/, match;
        while(match = re.exec(tpl)) {
            tpl = tpl.replace(match[0], data[match[1]])
        }
        return tpl;
    }

    function Pattern(script) {
        this.script = script;
        var regexpData = build(script.name);
        this.regexp = regexpData.regexp;
        this.paramNames = regexpData.paramNames;
    }

    Pattern.prototype.matches = function matches(commandName) {
        var match = this.regexp.exec(commandName);

        if (!match){
            return false;
        }

        this.params = {};

        for(var i = 0; i < this.paramNames.length; ++i) { // если подходит, сохраняем значения параметров
            this.params[this.paramNames[i]] = match[i + 1];
        }

        return true;
    };

    Pattern.prototype.getDataForExecution = function getDataForExecution() {
        // Простой хак, чтобы не обходить объект рекурсивно при замене параметров на их значения в конфиге
        return JSON.parse(TemplateEngine(JSON.stringify(this.script), this.params)) 
    };

    module.exports = Pattern;

Ну и файл с кодом, который непосредственно запустит тот или иной таск:

    require('dotenv').config({silent: true});

    var spawn = require('child_process').spawn;

    module.exports = function run(script, callback) {

        var argv = process.argv.splice(3);
        var command = (typeof script === 'string' ? script : script.command) + ' ' + argv.join(' ');

        script.env = script.env || {};

        var env = {};
        Object.assign(env, process.env, script.env);

        var sh = 'sh', shFlag = '-c';
        if (process.platform === 'win32') {
            sh = 'cmd';
            shFlag = '/c';
            command = '"' + command.trim() + '"';
        }

        spawn(sh, [shFlag, command], {
            env: env,
            windowsVerbatimArguments: process.platform === 'win32',
            stdio: 'inherit'
        }).on('close', callback);
    };

Скажу честно, последний, по большей части, я взял из проверенных временем исходников better-npm-run, добавив лишь callback после завершения дочернего процесса.

Выкладывание пакета в публичный доступ

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

    {
        "name": "prun",
        "version": "1.0.1", // версия
        "author": "Risent Veber <risentveber@gmail.com>", // имя автора
        "description": "Comfortable scripts runner that supports simple templates",
        "repository": {
            "type": "git", // адрес и тип
            "url": "git://github.com/risentveber/prun.git" // репозитория
        },
        "license": "MIT", // лицензия
        "main": "index.js", // точка входа
        "scripts": { // скрипты для тестирования travis'oм
            "test:case1": "node index.js case:1",
            "test:case2": "node index.js case:2:special",
            "test": "npm run test:case1 && npm run test:case2"
        },
        "bin": {
            "prun": "index.js" // имя, под которым наша утилита будет запускаться из консоли
        },
        "keywords": [ // ключевые слова, по которым идет поиск пакета другими разработчиками
            "script",
            "runner",
            "task",
            "pattern",
            "run",
            "manager"
        ],
        "dependencies": {
            "dotenv": "^2.0.0"
        }
    }

Согласно документации https://docs.npmjs.com/getting-started/publishing-npm-packages для этого необходимо всего три шага:

  1. Регистрируемся на сайте www.npmjs.com, из-под созданной учетки будем выкладывать пакет
  2. npm login - здесь просто логинимся под только что созданной учетной записью
  3. npm publish - собственно публикация(!NB опубликовать каждую версию можно только один раз)

Вывод

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

P.S. Спасибо Кириллу за его сайт и книгу: в свое время они оказали колоссальное положительное влияние на меня как разработчика.