#!/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);