vd-plugins/build.mjs
kmmiio99o 19168b1238 Add advanced CLI UI and logging to build and serve scripts
- Enhance `build.mjs` and `serve.mjs` with colorful banners, progress
bars, tables, and section headers for a professional CLI experience -
Add detailed build summaries, error reporting, and plugin statistics to
`build.mjs` - Improve argument parsing and verbose logging in
`build.mjs` - Add plugin authors to Last.fm manifest - Refactor Last.fm
plugin code for consistent formatting and improved settings typing - Add
plugin and file statistics, network interface table, and graceful
shutdown to `serve.mjs` - Remove `.vscode/settings.json` from repo
2025-10-04 11:16:42 +02:00

651 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import commonjs from "@rollup/plugin-commonjs";
import nodeResolve from "@rollup/plugin-node-resolve";
import { createHash } from "crypto";
import { readFile, readdir, writeFile, stat } from "fs/promises";
import { argv } from "process";
import { rollup, watch } from "rollup";
import swc from "@swc/core";
import esbuild from "rollup-plugin-esbuild";
// ═══════════════════════════════════════════════════════════════════════════════
// 🎨 STYLING & ANIMATIONS
// ═══════════════════════════════════════════════════════════════════════════════
const colors = {
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
italic: "\x1b[3m",
underline: "\x1b[4m",
// Standard colors
black: "\x1b[30m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
white: "\x1b[37m",
// Bright colors
brightRed: "\x1b[91m",
brightGreen: "\x1b[92m",
brightYellow: "\x1b[93m",
brightBlue: "\x1b[94m",
brightMagenta: "\x1b[95m",
brightCyan: "\x1b[96m",
brightWhite: "\x1b[97m",
// Backgrounds
bgRed: "\x1b[41m",
bgGreen: "\x1b[42m",
bgYellow: "\x1b[43m",
bgBlue: "\x1b[44m",
bgMagenta: "\x1b[45m",
bgCyan: "\x1b[46m",
};
const symbols = {
// Build status
success: "✓",
error: "✗",
warning: "⚠",
info: "ℹ",
// Activities
building: "🔨",
watching: "👁",
rocket: "🚀",
gear: "⚙",
lightning: "⚡",
fire: "🔥",
sparkles: "✨",
// UI elements
arrow: "→",
bullet: "•",
star: "★",
diamond: "◆",
// Box drawing
boxLight: "─",
boxHeavy: "━",
boxDouble: "═",
cornerTL: "┌",
cornerTR: "┐",
cornerBL: "└",
cornerBR: "┘",
cross: "┼",
teeLeft: "├",
teeRight: "┤",
vertical: "│",
};
const spinners = {
dots: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
line: ["│", "╱", "─", "╲"],
bounce: ["⠁", "⠂", "⠄", "⠂"],
pulse: ["●", "◐", "◑", "◒", "◓", "◔", "◕", "◖", "◗"],
};
// ═══════════════════════════════════════════════════════════════════════════════
// 🛠 UTILITIES
// ═══════════════════════════════════════════════════════════════════════════════
function formatTime(ms) {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
}
function formatSize(bytes) {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
function createGradient(text, startColor, endColor) {
// Simple gradient effect by alternating colors
return text
.split("")
.map((char, i) => {
const color = i % 2 === 0 ? startColor : endColor;
return `${color}${char}${colors.reset}`;
})
.join("");
}
function createBox(content, title = "", style = "light") {
const lines = content.split("\n");
const maxLength = Math.max(
...lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").length),
title.length,
);
const width = maxLength + 4;
const horizontal =
style === "heavy"
? symbols.boxHeavy
: style === "double"
? symbols.boxDouble
: symbols.boxLight;
let result = "";
// Top border
result += `${symbols.cornerTL}${horizontal.repeat(width - 2)}${symbols.cornerTR}\n`;
// Title
if (title) {
const padding = Math.floor((width - title.length - 4) / 2);
result += `${symbols.vertical} ${" ".repeat(padding)}${colors.bold}${title}${colors.reset}${" ".repeat(padding)} ${symbols.vertical}\n`;
result += `${symbols.teeLeft}${horizontal.repeat(width - 2)}${symbols.teeRight}\n`;
}
// Content
lines.forEach((line) => {
const cleanLine = line.replace(/\x1b\[[0-9;]*m/g, "");
const padding = width - cleanLine.length - 4;
result += `${symbols.vertical} ${line}${" ".repeat(padding)} ${symbols.vertical}\n`;
});
// Bottom border
result += `${symbols.cornerBL}${horizontal.repeat(width - 2)}${symbols.cornerBR}`;
return result;
}
class Spinner {
constructor(text = "", spinnerType = "dots") {
this.text = text;
this.frames = spinners[spinnerType];
this.current = 0;
this.isSpinning = false;
this.interval = null;
}
start() {
if (this.isSpinning) return;
this.isSpinning = true;
this.interval = setInterval(() => {
process.stdout.write(
`\r${colors.cyan}${this.frames[this.current]}${colors.reset} ${this.text}`,
);
this.current = (this.current + 1) % this.frames.length;
}, 100);
}
succeed(text) {
this.stop();
console.log(
`${colors.green}${symbols.success}${colors.reset} ${text || this.text}`,
);
}
fail(text) {
this.stop();
console.log(
`${colors.red}${symbols.error}${colors.reset} ${text || this.text}`,
);
}
stop() {
if (!this.isSpinning) return;
this.isSpinning = false;
clearInterval(this.interval);
process.stdout.write("\r\x1b[K"); // Clear line
}
}
function createProgressBar(current, total, width = 40, showPercentage = true) {
const percentage = Math.round((current / total) * 100);
const filled = Math.round((current / total) * width);
const empty = width - filled;
const filledBar = `${colors.brightCyan}${"█".repeat(filled)}${colors.reset}`;
const emptyBar = `${colors.dim}${"░".repeat(empty)}${colors.reset}`;
let result = `${filledBar}${emptyBar}`;
if (showPercentage) {
result += ` ${colors.bold}${percentage.toString().padStart(3)}%${colors.reset}`;
}
return result;
}
// ═══════════════════════════════════════════════════════════════════════════════
// 🎭 LOGGER
// ═══════════════════════════════════════════════════════════════════════════════
const logger = {
banner() {
const title = createGradient(
"VENDETTA PLUGIN BUILDER",
colors.brightCyan,
colors.brightMagenta,
);
const subtitle = `${colors.dim}Professional-grade TypeScript plugin compilation${colors.reset}`;
console.log();
console.log(` ${symbols.rocket} ${title}`);
console.log(` ${subtitle}`);
console.log();
},
section(title) {
const line = symbols.boxHeavy.repeat(60);
console.log(`\n${colors.brightBlue}${line}${colors.reset}`);
console.log(`${colors.bold}${colors.brightBlue} ${title}${colors.reset}`);
console.log(`${colors.brightBlue}${line}${colors.reset}\n`);
},
info(message, icon = symbols.info) {
console.log(`${colors.brightBlue}${icon}${colors.reset} ${message}`);
},
success(message, icon = symbols.success) {
console.log(`${colors.brightGreen}${icon}${colors.reset} ${message}`);
},
warning(message, icon = symbols.warning) {
console.log(`${colors.brightYellow}${icon}${colors.reset} ${message}`);
},
error(message, icon = symbols.error) {
console.log(`${colors.brightRed}${icon}${colors.reset} ${message}`);
},
plugin: {
building(name) {
return new Spinner(
`${colors.bold}${name}${colors.reset} ${colors.dim}Building...${colors.reset}`,
"pulse",
);
},
success(name, time, size) {
const timeStr = `${colors.dim}${time}${colors.reset}`;
const sizeStr = size ? `${colors.dim}${size}${colors.reset}` : "";
console.log(
`${colors.brightGreen}${symbols.success}${colors.reset} ${colors.bold}${name}${colors.reset} ${timeStr} ${sizeStr}`,
);
},
error(name, error) {
console.log(
`${colors.brightRed}${symbols.error}${colors.reset} ${colors.bold}${name}${colors.reset}`,
);
if (error) {
console.log(`${colors.red}${colors.dim} ${error}${colors.reset}`);
}
},
watching(name, time) {
const timeStr = `${colors.dim}${time}${colors.reset}`;
console.log(
`${colors.brightCyan}${symbols.lightning}${colors.reset} ${colors.bold}${name}${colors.reset} ${colors.dim}rebuilt${colors.reset} ${timeStr}`,
);
},
},
table(headers, rows) {
const colWidths = headers.map((header, i) =>
Math.max(
header.length,
...rows.map(
(row) =>
(row[i] || "").toString().replace(/\x1b\[[0-9;]*m/g, "").length,
),
),
);
// Header
const headerRow = headers
.map(
(header, i) =>
`${colors.bold}${header.padEnd(colWidths[i])}${colors.reset}`,
)
.join(` ${colors.dim}│${colors.reset} `);
console.log(` ${headerRow}`);
// Separator
const separator = colWidths
.map((width) => symbols.boxLight.repeat(width))
.join(`${colors.dim}─┼─${colors.reset}`);
console.log(` ${colors.dim}${separator}${colors.reset}`);
// Rows
rows.forEach((row) => {
const formattedRow = row
.map((cell, i) => {
const cleanCell = (cell || "")
.toString()
.replace(/\x1b\[[0-9;]*m/g, "");
const padding = colWidths[i] - cleanCell.length;
return cell + " ".repeat(padding);
})
.join(` ${colors.dim}│${colors.reset} `);
console.log(` ${formattedRow}`);
});
},
summary(stats) {
const { built, failed, total, totalTime, watchMode } = stats;
const rows = [
["Total Plugins", total.toString()],
["Built Successfully", `${colors.brightGreen}${built}${colors.reset}`],
[
"Failed",
failed > 0
? `${colors.brightRed}${failed}${colors.reset}`
: `${colors.dim}0${colors.reset}`,
],
["Total Time", formatTime(totalTime)],
[
"Mode",
watchMode
? `${colors.brightCyan}Watch${colors.reset}`
: `${colors.dim}Build${colors.reset}`,
],
];
this.table(["Metric", "Value"], rows);
},
};
// ═══════════════════════════════════════════════════════════════════════════════
// 🚀 MAIN EXECUTION
// ═══════════════════════════════════════════════════════════════════════════════
const args = argv.slice(2);
console.clear();
// Parse arguments
const inputPlugins = args.filter((x) => !x.startsWith("-"));
const flags = args.map((x) => x.toLowerCase()).filter((x) => x.startsWith("-"));
const isWatch = flags.includes("--watch") || flags.includes("-w");
const isVerbose = flags.includes("--verbose") || flags.includes("-v");
const toBuild = inputPlugins.length ? inputPlugins : await readdir("./plugins");
// Display banner
logger.banner();
// Build configuration
const config = {
plugins: toBuild.length,
mode: isWatch ? "Watch" : "Build",
verbose: isVerbose,
startTime: Date.now(),
};
logger.info(
`${colors.dim}Found ${colors.reset}${colors.bold}${config.plugins}${colors.reset}${colors.dim} plugin(s) to ${config.mode.toLowerCase()}${colors.reset}`,
);
if (isWatch) {
logger.info(
`${colors.dim}Running in watch mode - changes will trigger rebuilds${colors.reset}`,
);
}
// Build statistics
let built = 0;
let failed = 0;
const buildResults = [];
const startTime = Date.now();
// Build all plugins
logger.section("Building Plugins");
for (let i = 0; i < toBuild.length; i++) {
const plugin = toBuild[i];
if (!isWatch) {
console.log(
`\n ${createProgressBar(i, toBuild.length)} ${colors.dim}(${i}/${toBuild.length})${colors.reset}`,
);
}
const pluginStartTime = Date.now();
let spinner;
try {
if (!plugin.endsWith(".ts")) {
spinner = logger.plugin.building(plugin);
spinner.start();
const result = await buildPlugin(plugin);
const buildTime = Date.now() - pluginStartTime;
spinner.stop();
logger.plugin.success(
plugin,
formatTime(buildTime),
result.size ? formatSize(result.size) : "",
);
buildResults.push({
name: plugin,
status: "success",
time: buildTime,
size: result.size || 0,
});
built++;
}
} catch (e) {
if (spinner) spinner.stop();
logger.plugin.error(plugin, isVerbose ? e.stack : e.message);
buildResults.push({
name: plugin,
status: "error",
time: Date.now() - pluginStartTime,
error: e.message,
});
failed++;
}
}
// Final progress
if (!isWatch) {
console.log(
`\n ${createProgressBar(toBuild.length, toBuild.length)} ${colors.dim}(${toBuild.length}/${toBuild.length})${colors.reset}`,
);
}
// Summary
logger.section("Build Summary");
const totalTime = Date.now() - startTime;
logger.summary({
built,
failed,
total: toBuild.length,
totalTime,
watchMode: isWatch,
});
// Results table
if (buildResults.length > 0 && !isWatch) {
console.log();
const tableRows = buildResults.map((result) => [
result.name,
result.status === "success"
? `${colors.brightGreen}${symbols.success} Success${colors.reset}`
: `${colors.brightRed}${symbols.error} Failed${colors.reset}`,
formatTime(result.time),
result.size ? formatSize(result.size) : `${colors.dim}N/A${colors.reset}`,
]);
logger.table(["Plugin", "Status", "Time", "Size"], tableRows);
}
// Final status
console.log();
if (failed > 0) {
logger.error(
`Build completed with ${failed} error(s) ${symbols.bullet} ${built} succeeded`,
symbols.warning,
);
} else {
logger.success(
`All plugins built successfully! ${symbols.sparkles}`,
symbols.fire,
);
}
// Watch mode notification
if (isWatch) {
console.log();
logger.info(
`Watching for changes... ${colors.dim}Press ${colors.reset}${colors.bold}Ctrl+C${colors.reset}${colors.dim} to exit${colors.reset}`,
symbols.watching,
);
}
console.log();
// ═══════════════════════════════════════════════════════════════════════════════
// 🔧 BUILD FUNCTION
// ═══════════════════════════════════════════════════════════════════════════════
async function buildPlugin(plugin) {
if (plugin.endsWith(".ts")) return {};
const manifest = Object.assign(
JSON.parse(await readFile("./base_manifest.json")),
JSON.parse(await readFile(`./plugins/${plugin}/manifest.json`)),
);
const entry = "index.js";
const outPath = `./dist/${plugin}/${entry}`;
/** @type {import("rollup").RollupOptions} */
const options = {
input: `./plugins/${plugin}/${manifest.main}`,
output: {
file: outPath,
globals(id) {
if (id.startsWith("@vendetta"))
return id.substring(1).replace(/\//g, ".");
const map = {
react: "window.React",
"react-native": "vendetta.metro.common.ReactNative",
};
return map[id] || null;
},
format: "iife",
compact: true,
exports: "named",
inlineDynamicImports: true,
},
onwarn: (warning) => {
if (
![
"UNRESOLVED_IMPORT",
"MISSING_NAME_OPTION_FOR_IIFE_EXPORT",
"CIRCULAR_DEPENDENCY",
].includes(warning.code) &&
isVerbose
) {
logger.warning(`${plugin}: ${warning.message}`);
}
},
plugins: [
nodeResolve({
resolveOnly: (id) => !["react", "react-native"].includes(id),
}),
commonjs(),
{
name: "swc",
transform(code, id) {
return swc.transform(code, {
filename: id,
jsc: {
parser: {
syntax: "typescript",
tsx: true,
},
externalHelpers: true,
},
env: {
targets: "defaults",
include: ["transform-classes", "transform-arrow-functions"],
},
});
},
},
esbuild({ minify: true }),
],
};
const applyHash = async () => {
const content = await readFile(outPath);
return Object.assign(manifest, {
hash: createHash("sha256").update(content).digest("hex"),
main: entry,
});
};
if (!isWatch) {
const bundle = await rollup(options);
await bundle.write(options.output);
await bundle.close();
await applyHash();
await writeFile(`./dist/${plugin}/manifest.json`, JSON.stringify(manifest));
// Get file size
const stats = await stat(outPath);
return { size: stats.size };
}
const watcher = watch(options);
return await new Promise((resolve, reject) => {
watcher.on("event", async (event) => {
switch (event.code) {
case "BUNDLE_START":
// Silent in watch mode, spinner handles this
break;
case "BUNDLE_END": {
event.result.close();
await applyHash();
await writeFile(
`./dist/${plugin}/manifest.json`,
JSON.stringify(manifest),
);
const buildTime = formatTime(event.duration || 0);
logger.plugin.watching(plugin, buildTime);
resolve({ size: 0 });
break;
}
case "ERROR":
case "FATAL":
logger.plugin.error(
plugin,
isVerbose ? event.error.stack : event.error.message,
);
reject(event.error);
break;
}
});
});
}