bin_wpw-cli.js

#!/usr/bin/env node

/**
 * @file bim/wpw-cli.js
 * @copyright 2025 SPMHome, LLC
 * @author Scott Meesseman @spmeesseman
 *//** */

const webpack = require("webpack");
const WpwCache = require("../services/cache");
const WpwWrapper = require("../core/wrapper");
const { cliWrap } = require("@spmhome/cli-utils");
const { withColor } = require("@spmhome/log-utils");
const { STATS_JSON_SLUG } = require("../utils/constants");
const { cliArgMap, cliOptions } = require("./cli-options");
const { formatJsError, getUniqBuildKey, getUniqKey } = require("../utils/utils");
const {
    pushReturn, isEmpty, stripAnsiColorCodes, isNumber, isArray, epochToMSMS, pluralize, isError, merge, isNumeric
} = require("@spmhome/type-utils");

const wp = /** @type {WebpackType} */(webpack);


/**
 * @class WpWCli
 * @since 1.4.0
 * @description Shareable cli wrapper and other related stuff
 */
class WpWCli
{
    /**
     * @private
     * @type {WpwCmdLineArgs}
     */
    args;
    /**
     * @private
     * @type {number}
     */
    assetsEmitted = 0;
    /**
     * @private
     * @type {boolean}
     */
    displayWpWarnings = false;
    /**
     * @private
     * @type {boolean}
     */
    failOnWarnings = false;
    /**
     * @private
     * @type {WpwCache}
     */
    store;
    /**
     * @type {webpack.MultiCompiler}
     */
    multiCompiler;
    /**
     * @private
     * @type {WpwLogger}
     */
    logger;
    /**
     * @private
     * @type {WpwWrapper}
     */
    wpw;


    /**
     * @param {WpwCmdLineArgs} args
     */
    constructor(args)
    {
        this.args = merge({}, args);
    }


    /**
     * @returns {Promise<number>}
     */
    cliExec = () => /** @type {Promise<number>} */(new Promise((ok, fail) =>
    {
        const _err =  (/** @type {Error} */ e) =>
        {  let match;
            const rgx = /l([1-9])_handled/;
            if ((match = e.message.match(rgx)) !== null)
            {   try
                {   this.wpw?.dispose();
                    return ok(isNumeric(match[1]) ? parseInt(match[1], 10) : 3);
                } catch(e2) { console.error(e2); }
            }
            if (this.logger && !this.logger.disposed) { this.logger.error(e); } else { console.error(e); }
            try { this.wpw?.dispose(); } catch(e2) { console.error(e2); }
            fail(new Error("l6_handled"));
        };

        const _cb = (/** @type {any} */ err, /** @type {any} */ stats) =>
        {
            this.shutdown(this.multiCompiler, stats, err, Date.now() - start)
                .then((rc) => { this.wpw?.dispose(); ok(rc); })
                .catch(_err.bind(this));
        };

        const start = Date.now();
        try
        {   WpwWrapper.create(this.args, wp).then((wpw) =>
            {   this.wpw = wpw;
                this.logger = this.wpw.logger;
                if (wpw.cli.help || wpw.cli.version)
                {   this.wpw?.dispose();
                    ok(0);
                }
                else
                {   this.store = new WpwCache({
                        dir: wpw.global.cacheDir, slug: STATS_JSON_SLUG, logger: this.logger, crypto: this.wpw.crypto
                    });
                    const wpExports = this.wpw.builds.map((b) => b.wpc);
                    this.multiCompiler = webpack(wpExports, _cb.bind(this));
                }
            }).catch(_err.bind(this));
        } catch(e) { _err.call(this, e); }
    }));


    /**
     * @param {WpwBuild} build
     * @param {boolean} failed
     * @returns {SpmhCachedLogEntry[]}
     */
    getBuildMessages = (build, failed) =>
    {
        /** @type {SpmhCachedLogEntry[]} */
        const stats = [];
        if (build)
        {   stats.push(
                [ build, build.printMessages, failed ]
            );
        }
        else {
            const l = this.logger;
            stats.push(
                [ l, l.sep, 1, null, l.colors[l.options.color] ]
            );
        }
        return stats;
    };


    /**
     * @private
     * @param {string} s
     * @param {WpwBuild} build
     * @param {boolean} failed
     * @param {SpmhLogIconString} statusIcon
     * @returns {SpmhCachedLogEntry[]}
     */
    getBuildTitle = (s, build, failed, statusIcon) =>
    {
        const xTags = !build.isRebuild  ? [] : [ "italic(rebuild)" ],
              l = this.logger,
              bl = build.logger,
              clr = bl.colors[bl.options.color],
              msgBaseParts =  s.replace(/:$/, "").split(" "),
              msgParts = msgBaseParts.splice(3),
              buildTxt = !build.isRebuild ? "build" : "rebuild";

        xTags.push(...msgParts.map((m) => m.replace(/^.+::/, "[")));

        if (!failed)
        {
            const xKey = build.buildCount.toString() + (!build.isRebuild ? "" : "_rebuild"),
                  bKey = getUniqBuildKey(build, true, xKey, "_");
            this.store.data[bKey] = this.recordStats(bKey, xTags, build.elapsed, build.logger);
        }

        /** @type {SpmhCachedLogEntry[]} */
        const stats = [
            [ bl, bl.sep, 1, statusIcon, clr ],
            [ bl, bl.write, " " + msgBaseParts[2].replace(`[${buildTxt}::`, "").replace("]", "").toUpperCase(),
              1, "", statusIcon, l.colors.white, false, 0, xTags, clr, l.colors.grey, [ 1, 2 ]],
            [ bl, bl.sep, 1, statusIcon, clr, false ]
        ];
        if (this.args.linebreaks) {
            stats.push([ bl, bl.blank, 1, statusIcon ]);
        }

        return stats;
    };


    /**
     * @param {any} build
     * @param {SpmhLogIconString} statusIcon
     * @returns {SpmhCachedLogEntry[]}
     */
    getCompilationMessages = (build, statusIcon) =>
    {
        const stats = [],
              bl = build.logger;
        if (build.compilation)
        {
            const errors = build.compilation.getErrors();
            if (errors)
            {
                /** @type {Array<webpack.WebpackError | SpmhError | any>} */
                (errors).filter((e) => !e.build).forEach((e) => {
                    stats.push([ bl, bl.write, /** @type {webpack.WebpackError} */(e), 1, "", statusIcon ]);
                });
            }
            if (this.displayWpWarnings)
            {
                const warnings = build.compilation.getWarnings();
                if (warnings)
                {
                    /** @type {Array<webpack.WebpackError | SpmhError | any>} */
                    (warnings).filter((e) => !e.build).forEach((e) => {
                        stats.push([ bl, bl.warning, /** @type {webpack.WebpackError} */(e), 1, "", statusIcon ]);
                    });
                }
            }
        }
        return stats;
    };


    /**
     * @param {string} printedStats
     * @param {WpwLogger} wpwLogger
     * @param {boolean} failed
     * @param {Error} [err]
     * @returns {{ stats: SpmhCachedLogEntry[], errorCount: number, warningCount: number, infoCount: number }}
     */
    getFormattedStats = (printedStats, wpwLogger, failed, err) =>
    {
        const fmtStats = /** @type {SpmhCachedLogEntry[]} */([]);
        let line1 = true, bIdx = 0, errorCount = 0,
            infoCount = 0, warningCount = 0;
        try
        {   let b = this.wpw.builds[bIdx],
                bClr = b.logger.options.colors.buildBracket || "spmh_blue",
                bIcon = withColor(!failed ? "✔" : "✘", bClr);
            const boldRgx = /\x1B\[1m/g, whiteRgx = /\x1B\[22m/g,
                assetNameRgx = /\x1B\[[0-9]{2}m *((?:@|)[a-z0-9./*: _-]+?(?:\.[a-zA-Z0-9]{2,})+)/,
                ign = (b.log.suppress || []).map((i) => new RegExp(i)),
                // statsRgx = /(?:\x1B\[[0-9;]+m)?\[(?:\x1B\[[0-9;]+m)?([a-z0-9\.\/:_-]+?)(?:\x1B\[[0-9;]+m)?\]/g,
                statsRgx = /(?:\x1B\[[0-9;]+m)?\[(?:\x1B\[[0-9;]+m)?([a-z0-9./: _-]+?)(?:\x1B\[[0-9;]+m)?\]/g;

            if (printedStats)
            {
                for (let s of printedStats.replace(/^\s+/, "").split("\n").filter((s) => !!s))
                {
                    if (!line1 && /.+? \(webpack [0-9.]+\)/.test(s))
                    {
                        if (!failed)
                        {
                            const cMsgs = this.getCompilationMessages(b, bIcon);
                            // bMsgs = this.getBuildMessages(b, failed);
                            if (cMsgs.length > 0) {
                                // fmtStats.push(...bErrors, ...cErrors,  [ b.logger, b.logger.sep, 1, bIcon, bClr ]);
                                fmtStats.push(...cMsgs,  [ b.logger, b.logger.sep, 1, bIcon, bClr ]);
                            }
                        }
                        // else {
                            fmtStats.push([ b, b.printMessages, failed ]);
                        // }
                        fmtStats.push([ b.logger, b.logger.sep, 1, bIcon, bClr ]);
                        if (this.args.linebreaks) {
                            fmtStats.push([ b.logger, b.logger.blank, 1 ]);
                        }
                        if (++bIdx > this.wpw.builds.length - 1) {
                            bIdx = 0;
                        }
                        line1 = true;
                    }
                    else if (line1)
                    {
                        b = this.wpw.builds[bIdx];
                        errorCount += b.errorCount;
                        warningCount += b.warningCount;
                        infoCount += b.infoCount;
                        if (errorCount >= 25) {
                            break;
                        }
                        bClr = b.logger.options.colors.buildBracket || "spmh_blue";
                        bIcon = withColor(!failed ? "✔" : "✘", bClr);
                        const tStats = this.getBuildTitle(s, b, failed, bIcon);
                        fmtStats.push(...tStats);
                        line1 = false;
                    }
                    else
                    {
                        const nakedS = stripAnsiColorCodes(s.trim());
                        if (ign.length === 0 || ign.every((/** @type {RegExp} */ i) => !i.test(nakedS)))
                        {
                            const defClr = b.logger.colors.default;
                            if (nakedS.trimStart().startsWith(b.wpc.name))
                            {
                                s = s.replace(/ +\[.+?\)/, "");
                            }
                            // if (/ @[a-zA-Z]/.test(nakedS))
                            // {
                            //     s = s.replace(/@[a-zA-Z]/, (m) => m.replace("@", "@ "));
                            // }
                            s = s.replace(boldRgx, "")
                                .replace(whiteRgx, `\x1B[38;2;${defClr.join(";")}m`)
                                // .replace(statsRgx, (_, g) => b.logger.tag(g, null, b.logger.colors.lightgrey))
                                .replace(statsRgx, (_, g) => b.logger.tag(g, null, b.logger.colors.lightgrey))
                                // .replace(statsIntRgx, (_, g) => b.logger.tag(g, defClr, b.logger.colors.lightgrey))
                                .replace(assetNameRgx, (_, g) => `\x1B[37;3m${g}\x1B[23m`).trim();
                            if (!failed || !(/^[0-9]+:[0-9]+-[0-9+:[0-9]+$/).test(nakedS))
                            {
                                if (nakedS.startsWith("asset ") && nakedS.includes("emitted"))
                                {
                                    ++this.assetsEmitted;
                                }
                                else if (nakedS.startsWith("@"))
                                {
                                    s = s.replace("@", "  @");
                                }
                                else if (nakedS.startsWith("at"))
                                {
                                    s = s.replace("at", "  at");
                                }
                                else if (nakedS.startsWith("assets by "))
                                {
                                    const match = nakedS.match(/ ([0-9]+) assets$/);
                                    if (match) {
                                        this.assetsEmitted += parseInt(match[1], 10);
                                    }
                                }
                                else if (nakedS.startsWith("+ "))
                                {
                                    const match = nakedS.match(/\+ ([0-9]+) assets$/);
                                    if (match) {
                                        this.assetsEmitted += parseInt(match[1], 10);
                                    }
                                }
                                else
                                {   let match = nakedS.match(/ERROR in (.+)$/);
                                    if (match)
                                    {   ++errorCount;
                                        fmtStats.push([
                                            b.logger, b.logger.write,
                                            `wp compiler reported internal error @ '${match[1]}'`, 1, "", bIcon
                                        ]);
                                    }
                                    else
                                    {   match = nakedS.match(/ (.+?) problem occurred\\.$/);
                                        if (match)
                                        {   ++errorCount;
                                            fmtStats.push([
                                                b.logger, b.logger.write,
                                                `wp compiler reported internal '${match[1]}' problem`, 1, "", bIcon
                                            ]);
                                        }
                                    }
                                }
                                fmtStats.push([ b.logger, b.logger.write, s, 1, "", bIcon ]);
                            }
                        }
                    }
                }
            }
            else
            {
                for (const b of this.wpw.builds)
                {
                    const tStats = this.getBuildTitle(b.wpc.name, b, failed, bIcon),
                        bErrors = this.getBuildMessages(b, failed),
                        cErrors = this.getCompilationMessages(b, bIcon)
                                    .filter((e) => !err || (e[2] &&
                                    !(stripAnsiColorCodes(`${e[2]}`).replace(/[0-9]+ of [0-9]+/, "").includes(
                                        stripAnsiColorCodes(err.message).replace(/[0-9]+ of [0-9]+/, "")
                                    ))));
                    errorCount += b.errorCount;
                    fmtStats.push(...tStats, ...bErrors, ...cErrors);
                    if (errorCount >= 25)
                    {
                        break;
                    }
                }
            }

            if (this.assetsEmitted === 0 || errorCount > 0)
            {
                fmtStats.forEach((v) => {
                    if (v.length >= 10 && isArray(v[9], false) && v[9].find((t) => /fast|slow/.test(t))) {
                        v[9].splice(v[9].findIndex((t) => /fast|slow/.test(t), 1));
                    }
                });
            }
        }
        catch(e)
        {
            fmtStats.push([ wpwLogger, wpwLogger.error, e ]);
        }

        return { stats: fmtStats, errorCount, warningCount, infoCount };
    };


    /**
     * @param {webpack.MultiCompiler} multiCompiler
     * @param {webpack.MultiStats} stats
     * @param {any} err
     * @param {number} tm
     * @returns {Promise<number>}
     */
    shutdown = async (multiCompiler, stats, err, tm) =>
    {
        const l = this.logger;
        for (let retry = 0; retry < 10 && this.wpw.builds.find((b) => b.state !== "done"); ++retry)
        {   l.write("waiting for active builds to exit...");
            await new Promise((ok) => setTimeout(ok, 500));
        }

        const o = l.options,
              rc = !err && stats && !stats.hasErrors() ? 0 : (err ? 1 : 2),
              /** @type {SpmhCachedLogEntry[]} */fmtStats = [],
              statusBlank = () => /** @type {SpmhCachedLogEntry} */([ l, l.blank, 1, statusIcon ]),
              statusSep = () => /** @type {SpmhCachedLogEntry} */([ l, l.sep, 1, statusIcon, statusClr ]),
              name = `${this.wpw.pkgJson.scopedName.name.toUpperCase()} v${this.wpw.pkgJson.version}`;

        let printedStats = "",
            failed = rc !== 0 || !!err || stats?.hasErrors(),
            statusText = !failed ? "success" : "failed",
            /** @type {SpmhLogIconString} */
            statusIcon = l.icons.color[statusText],
            /** @type {SpmhLogColor} */
            statusClr = !failed ? "success" : "failed";

        l.level ||= 1;
        l.start("begin shutdown");
        l.value("   has stats", !!stats, 2);
        l.value("   exit status", `failed::${failed} | exitcode::${rc} | error::${!!err}`, 1);

        if (stats)
        {   l.write("   get build statistics from multi-compiler", 2);
            /** @type {any} */
            const statsOptions = { children: multiCompiler.compilers.map((c) => c.options?.stats) };
            printedStats += stats.toString(statsOptions);
        }

        l.write("   format statistics", 1);
        const fStats = this.getFormattedStats(printedStats, l, failed, err),
              xTags = [ statusText, `emits::italic(${this.assetsEmitted})` ];

        fmtStats.push(
            statusSep(), [
                l, l.write, ` ${name} BUILD ${statusText.toUpperCase()}`, 1, "", statusIcon, "white",
                false, 0, [ `grey(exitcode::)italic(white(${rc}))` ], statusClr
            ], statusSep()
        );

        if (this.args.linebreaks) { fmtStats.push(statusBlank()); }
        fmtStats.push(...fStats.stats);

        if (isError(err) && ((!stats && fStats.errorCount === 0) || // if (err && (!stats || fStats.stats.length === 0))
                             fStats.stats.length === 0) && !/l[1-9]_handled/i.test(err.message))
        {
            fmtStats.push(statusSep(), [ l, l.error, formatJsError(err, "TOP LEVEL EXCEPTION") ], statusSep());
        }

        if (!failed)
        {
            // const b = Math.round(this.assetsEmitted / 4) + (this.assetsEmitted > 0 ? 1 : 0)
            const // emitCtRange = Math.round(this.assetsEmitted / 4) + (this.assetsEmitted > 0 ? 1 : 0),
                  isRebuild = this.wpw.builds.every((b) => b.isRebuild),
                  xKey = this.wpw.buildCount.toString() + (!isRebuild ? "" : "_rebuild"),
                  buildInstKey = getUniqKey(this.wpw, xKey, "_");
            if (isRebuild) {// `all_${this.wpw.mode}_${this.wpw.buildCount}_${this.assetsEmitted}${rebuildKey}`
                xTags.push("italic(rebuild)");
            }
            this.store.data.all = this.recordStats(buildInstKey, xTags, tm, l);
        }
        else
        {
            l.write("   skip statistics processing on failure state", 3);
            xTags.push(`elapsed::italic(${epochToMSMS(tm, true)})`);
        }

        if (fStats.errorCount > 0) {
            xTags.splice(1, 0, `grey(errors::)italic(white(${fStats.errorCount}))`);
        }
        else if (err) {
            xTags.splice(1, 0, "grey(error::)italic(compiler)");
        }
        if (rc !== 0) {
            xTags.splice(1, 0, `grey(exitcode::)italic(white(${rc}))`);
        }
        const statusLine = [ l, l.write, " done", 1, "", statusIcon, l.colors.white, false, 0, xTags, statusClr ];

        l.success("shutdown complete", 1, "");
        l.blank(1);

        if (!failed && fStats.warningCount > 0)
        {
            statusClr = "warning";
            failed = this.failOnWarnings;
            statusIcon = l.icons.color.warning;
            statusText += ` (with ${fStats.warningCount} ${pluralize("warning", fStats.warningCount)})`;
            o.color = o.colors.buildBracket = o.colors.tagBracket = statusClr;
        }      //
        else  // change cli logger color to the status color
        {    // individual build colors stay the same, but tags change to the wpw-cli tags
            //
            o.color = o.colors.buildBracket = o.colors.tagBracket = statusClr;
        }

        // this.syncBuildTags();       // change all tags to [wpw][cli]
        this.wpw.syncLogConfigs(true, false);  // sync log configuration i.e. colors and lengths

        pushReturn(fmtStats, statusSep(), statusLine, statusSep()).filter((f) => !isEmpty(f)).forEach((f) =>
        {
            const scp = /** @type {any} */(f.shift()); /** @type {any} */(f.shift()).call(scp, ...f);
        });

        return rc;
    };


    /**
     * @private
     * @param {string} storeName
     * @param {string[]} xTags
     * @param {number} elapsed
     * @param {WpwLogger} logger
     * @returns {Record<string, string | number>}
     */
    recordStats = (storeName, xTags, elapsed, logger) =>
    {
        if (!this.store.data[storeName]) {
            this.store.data[storeName] = {};
        }
        const store = this.store.data[storeName],
              elapsedFmt = epochToMSMS(elapsed, true);

        logger.value(`   process statistics for state key ${storeName}`, `${elapsedFmt}`, 1);

        if (isNumber(store.fastest))
        {
            if (elapsed < store.fastest || store.fastest === 0)
            {
                xTags.push(`fast::palegreen(italic(${elapsedFmt}))`);
                store.fastestFmt = elapsedFmt;
                store.fastest = elapsed;
            }
            else {
                xTags.push(`fast::italic(${store.fastestFmt})`);
            }
        }
        else {
            store.fastestFmt = elapsedFmt;
            store.fastest = elapsed;
        }

        if (isNumber(store.slowest))
        {
            if (elapsed > store.slowest || store.slowest === 0)
            {
                xTags.push(`slow::rosered(italic(${elapsedFmt}))`);
                store.slowestFmt = elapsedFmt;
                store.slowest = elapsed;
            }
            else {
                xTags.push(`slow::italic(${store.slowestFmt})`);
            }
        }
        else {
            store.slowest = elapsed;
            store.slowestFmt = elapsedFmt;
        }

        store.count = isNumber(store.count) ? store.count + 1 : 1;
        store.total = isNumber(store.total) ? store.total + elapsed : elapsed;
        store.average = store.total / store.count;
        store.averageFmt = epochToMSMS(store.average, true);
        xTags.push(`avg::italic(${store.averageFmt})`);

        if (isNumber(store.last))
        {
            if (elapsed < store.last) {
                // xTags.push(`elapsed::italic(${elapsedFmt}) ${logger.icons.arrowDown}`);
                // xTags.push(`elapsed::italic(${elapsedFmt}) ${withColor(logger.icons.arrowDown, "limegreen")}`);
                xTags.push(`${withColor(elapsedFmt,"italic")}:${withColor(logger.icons.arrowDown, logger.color)}`);
                // xTags.push(`elapsed::${withColor(logger.icons.arrowDown, "limegreen")} italic(${elapsedFmt})`);
            }
            else if (elapsed > store.last) {
                // xTags.push(`elapsed::italic(${elapsedFmt}) ${logger.icons.arrowUp}`);
                // xTags.push(`elapsed::italic(${elapsedFmt}) ${withColor(logger.icons.arrowUp, "rosered")}`);
                xTags.push(`${withColor(elapsedFmt,"italic")}:${withColor(logger.icons.arrowUp, logger.color)}`);
                // xTags.push(`elapsed::${withColor(logger.icons.arrowUp, "rosered")}italic(${elapsedFmt})`);
            }
        }
        else {
            xTags.push(withColor(elapsedFmt,"italic"));
        }

        store.last = elapsed;
        store.lastFmt = elapsedFmt;

        this.store.save();
        return store;
    };


    syncBuildTags = () =>
    {
        const l = this.logger;
        this.wpw.builds.map((b) => b.logger).forEach((bl) =>
        {
            bl.options.envTag1 = l.options.envTag1;
            bl.options.envTag2 = l.options.envTag2;
            // bl2.options.color = bl2.options.colors.buildBracket || "spmh_blue";
            // bl.options.colors.buildText = l.options.colors.buildText;
            //  bl2.options.colors.buildBracket = statusClr;
            const envTagDiff = bl.envTagLen - l.envTagLen;
            bl.options.valuePad -= envTagDiff;
        });
    };
}

/** @type {SpmhCliExecutionFn} */
cliWrap(
    (/** @type {WpwCmdLineArgs} */args) => new Promise((ok, err) => { new WpWCli(args).cliExec().then(ok).catch(err); })
)(cliArgMap, cliOptions);