to-userscript
Version:
Converts simple browser extensions to userscripts
493 lines (426 loc) • 15.5 kB
JavaScript
const fs = require("fs").promises;
const path = require("path");
const tmp = require("tmp");
const chalk = require("chalk");
const ora = require("ora");
const unpack = require("./unpack");
const minify = require("./minify");
const debug = require("debug")("to-userscript:cli:workflow");
const { getLocale, getLocalizedName } = require("../locales");
const fetch = require("node-fetch");
async function determineSourceType(source) {
if (!source || typeof source !== "string") {
throw new Error("Source must be a non-empty string");
}
const urlRegex = /^https?:\/\//;
const chromeStoreRegex =
/chromewebstore\.google\.com\/detail\/([^\/]+)\/([a-z]{32})/;
const firefoxStoreRegex = /addons\.mozilla\.org.*\/addon\/([^\/]+)/;
if (urlRegex.test(source)) {
if (chromeStoreRegex.test(source)) {
return { type: "chrome-store", url: source };
} else if (firefoxStoreRegex.test(source)) {
return { type: "firefox-store", url: source };
} else {
return { type: "url", url: source };
}
}
try {
const stats = await fs.stat(source);
if (stats.isDirectory()) {
return { type: "directory", path: path.resolve(source) };
} else if (stats.isFile()) {
const ext = path.extname(source).toLowerCase();
if ([".crx", ".xpi", ".zip"].includes(ext)) {
return { type: "archive", path: path.resolve(source) };
} else {
throw new Error(
`Unsupported file type: ${ext}. Supported types: .crx, .xpi, .zip`
);
}
}
} catch (error) {
if (error.code === "ENOENT") {
throw new Error(`Source not found: ${source}`);
}
throw error;
}
throw new Error(`Unable to determine source type: ${source}`);
}
async function generateOutputPath(config, manifest, localizedName = null) {
if (config.output) {
return path.resolve(config.output);
}
// Use localized name if available, otherwise fall back to raw manifest name
const name = localizedName || manifest?.name || "converted-extension";
const version = manifest?.version || "1.0.0";
const target = config.target || "userscript";
const cleanName = name
.replace(/[^a-z0-9]+/gi, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase();
const extension = target === "userscript" ? ".user.js" : ".js";
const filename = `${cleanName}-${version}${extension}`;
return path.resolve(process.cwd(), filename);
}
async function checkOutputFile(outputPath, force, isDirectory = false) {
try {
await fs.access(outputPath);
if (!force) {
const itemType = isDirectory ? "directory" : "file";
throw new Error(
`Output ${itemType} already exists: ${outputPath}. Use --force to overwrite.`
);
}
const itemType = isDirectory ? "directory" : "file";
debug(`Output ${itemType} exists, will be overwritten due to --force flag`);
} catch (error) {
if (error.code !== "ENOENT") {
throw error;
}
}
}
async function createTempDirectory(customTempDir) {
const tempDirOptions = {
prefix: "to-userscript-",
unsafeCleanup: true,
};
if (customTempDir) {
tempDirOptions.dir = customTempDir;
}
return new Promise((resolve, reject) => {
tmp.dir(tempDirOptions, (err, tmpPath, cleanupCallback) => {
if (err) {
reject(
new Error(`Failed to create temporary directory: ${err.message}`)
);
} else {
debug("Created temporary directory: %s", tmpPath);
resolve({ tmpPath, cleanupCallback });
}
});
});
}
async function runDownload(config) {
const download = require("./download");
const spinner = ora();
let tempCleanup = null;
try {
debug("Starting download with config: %o", config);
// Source Analysis
spinner.start("Analyzing source...");
const sourceInfo = await determineSourceType(config.source);
debug("Source type determined: %o", sourceInfo);
// URL Resolution
spinner.text = "Resolving download URL...";
const downloadUrl = await download.getDownloadableUrl(sourceInfo);
debug("Download URL resolved: %s", downloadUrl);
// Output Path Determination
spinner.text = "Determining output path...";
let outputPath;
if (config.output) {
const outputStat = await fs.stat(config.output).catch(() => null);
if (outputStat && outputStat.isDirectory()) {
// Output is a directory, generate filename
const headResponse = await fetch(downloadUrl, {
method: "HEAD",
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
},
}).catch(() => null);
const contentType = headResponse?.headers.get("content-type") || "";
const extension = download.guessFileExtension(downloadUrl, contentType);
const filename = `extension-${Date.now()}${extension}`;
outputPath = path.join(config.output, filename);
} else if (config.output.endsWith("/") || config.output.endsWith("\\")) {
// Treat as directory path
await fs.mkdir(config.output, { recursive: true });
const headResponse = await fetch(downloadUrl, {
method: "HEAD",
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
},
}).catch(() => null);
const contentType = headResponse?.headers.get("content-type") || "";
const extension = download.guessFileExtension(downloadUrl, contentType);
const filename = `extension-${Date.now()}${extension}`;
outputPath = path.join(config.output, filename);
} else {
// Treat as full file path
outputPath = path.resolve(config.output);
}
} else {
// No output specified, use current working directory
const headResponse = await fetch(downloadUrl, {
method: "HEAD",
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
},
}).catch(() => null);
const contentType = headResponse?.headers.get("content-type") || "";
const extension = download.guessFileExtension(downloadUrl, contentType);
const filename = `extension-${Date.now()}${extension}`;
outputPath = path.join(process.cwd(), filename);
}
debug("Output path determined: %s", outputPath);
// Overwrite Check
await checkOutputFile(outputPath, config.force);
// File Download
spinner.stop();
const downloadedFilePath = await download.downloadFile(
downloadUrl,
outputPath
);
// Completion
console.log(chalk.green("Download complete!"));
console.log(chalk.blue("📄 Downloaded:"), outputPath);
const stats = await fs.stat(outputPath);
console.log(chalk.blue("📊 Size:"), humanFileSize(stats.size, true));
let extractedDirPath = null;
if (config.extract) {
spinner.start("Extracting extension...");
const { tmpPath, cleanupCallback } = await createTempDirectory(
config.tempDir
);
tempCleanup = cleanupCallback;
const unpackedDir = await unpack.unpack(
downloadedFilePath,
path.join(tmpPath, "unpacked")
);
debug("Unpacked to: %s", unpackedDir);
spinner.text = "Reading manifest...";
const manifestPath = path.join(unpackedDir, "manifest.json");
const { parseManifest } = require("../manifestParser");
const manifestResult = await parseManifest(manifestPath, config.locale);
if (!manifestResult || !manifestResult.parsedManifest) {
throw new Error(
"Failed to parse manifest.json from the downloaded archive."
);
}
const { parsedManifest } = manifestResult;
const version = parsedManifest.version || "1.0.0";
spinner.succeed(`Read manifest for: ${parsedManifest.name} v${version}`);
spinner.start("Preparing final directory...");
const cleanName = (parsedManifest.name || "unnamed-extension")
.replace(/[^a-z0-9._-]+/gi, "-")
.replace(/--+/g, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase();
const finalDirName = `${cleanName}-${version}`;
extractedDirPath = path.join(
path.dirname(downloadedFilePath),
finalDirName
);
debug("Final extraction path: %s", extractedDirPath);
await checkOutputFile(extractedDirPath, config.force, true);
await fs.rename(unpackedDir, extractedDirPath);
spinner.succeed(chalk.green("Extraction complete!"));
console.log(chalk.blue("📂 Extracted to:"), extractedDirPath);
}
return {
success: true,
outputFile: outputPath,
extractedPath: extractedDirPath,
};
} catch (error) {
spinner.fail(chalk.red("Download failed"));
debug("Download error: %o", error);
throw error;
} finally {
if (tempCleanup && !config.keepTemp) {
try {
tempCleanup();
debug("Temporary files cleaned up");
} catch (cleanupError) {
console.warn(
chalk.yellow("Warning: Failed to clean up temporary files:"),
cleanupError.message
);
}
}
}
}
async function run(config) {
const spinner = ora();
let tempCleanup = null;
try {
debug("Starting conversion with config: %o", config);
// Determine source type
spinner.start("Analyzing source...");
const sourceInfo = await determineSourceType(config.source);
debug("Source type determined: %o", sourceInfo);
// Create temporary directory
const { tmpPath, cleanupCallback } = await createTempDirectory(
config.tempDir
);
tempCleanup = cleanupCallback;
let inputDir;
let downloadedFile = null;
// Handle different source types
if (sourceInfo.type === "directory") {
inputDir = sourceInfo.path;
spinner.succeed(`Source: Local directory (${path.basename(inputDir)})`);
} else if (sourceInfo.type === "archive") {
spinner.text = "Extracting archive...";
inputDir = await unpack.unpack(
sourceInfo.path,
path.join(tmpPath, "unpacked")
);
spinner.succeed(
`Source: Archive file (${path.basename(sourceInfo.path)})`
);
} else if (
sourceInfo.type === "chrome-store" ||
sourceInfo.type === "firefox-store" ||
sourceInfo.type === "url"
) {
const download = require("./download");
spinner.stop();
// spinner.text = "Downloading extension...";
const downloadPath = path.join(tmpPath, "download");
downloadedFile = await download.downloadExtension(
sourceInfo,
downloadPath
);
debug("Downloaded file: %s", downloadedFile);
spinner.start("Extracting downloaded archive...");
inputDir = await unpack.unpack(
downloadedFile,
path.join(tmpPath, "unpacked")
);
spinner.succeed(
`Source: Downloaded from ${sourceInfo.type} (${path.basename(downloadedFile)})`
);
}
// Verify manifest exists
const manifestPath = path.join(inputDir, "manifest.json");
try {
await fs.access(manifestPath);
} catch (error) {
throw new Error(
`No manifest.json found in extracted extension at: ${manifestPath}`
);
}
// Read manifest for output path generation
let manifest;
try {
const manifestContent = await fs.readFile(manifestPath, "utf-8");
manifest = JSON.parse(manifestContent);
debug("Manifest loaded: %s v%s", manifest.name, manifest.version);
} catch (error) {
throw new Error(`Failed to parse manifest.json: ${error.message}`);
}
const locale = await getLocale(manifest, manifestPath, config.locale);
// Generate output path
const localizedName = getLocalizedName(manifest, locale);
const outputPath = await generateOutputPath(
config,
manifest,
localizedName
);
debug("Output path: %s", outputPath);
// Check if output file exists
await checkOutputFile(outputPath, config.force);
// Convert using existing converter
spinner.start("Converting extension to userscript...");
const { convertExtension } = require("../convert");
const convertConfig = {
inputDir,
outputFile: outputPath,
target: config.target,
locale: config.locale,
ignoredAssets: config.ignoreAssets,
};
const result = await convertExtension(convertConfig);
debug("Conversion result: %o", result);
// Minify if requested
if (config.minify) {
spinner.text = "Minifying output...";
await minify.minifyScript(outputPath);
debug("Minification complete");
} else if (config.beautify) {
spinner.text = "Beautifying output...";
await minify.beautifyScript(outputPath);
debug("Beautification complete");
}
spinner.succeed(chalk.green(`Conversion complete!`));
// Display results
const stats = await fs.stat(outputPath);
console.log(
chalk.blue("📦 Extension:"),
chalk.bold(result.extension.name || "Unknown")
);
console.log(
chalk.blue("📋 Version:"),
result.extension.version || "Unknown"
);
if (result.extension.description) {
console.log(chalk.blue("📝 Description:"), result.extension.description);
}
console.log(chalk.blue("🎯 Target:"), config.target);
if (config.locale) {
console.log(chalk.blue("🌐 Locale:"), config.locale);
}
console.log(chalk.blue("📄 Output:"), outputPath);
console.log(chalk.blue("📊 Size:"), humanFileSize(stats.size, true));
if (config.minify) {
console.log(chalk.blue("🗜️ Minified:"), "Yes");
}
return { success: true, outputFile: outputPath };
} catch (error) {
spinner.fail(chalk.red("Conversion failed"));
debug("Conversion error: %o", error);
throw error;
} finally {
// Cleanup temporary files unless requested to keep them
if (tempCleanup && !config.keepTemp) {
try {
tempCleanup();
debug("Temporary files cleaned up");
} catch (cleanupError) {
console.warn(
chalk.yellow("Warning: Failed to clean up temporary files:"),
cleanupError.message
);
}
} else if (config.keepTemp && tempCleanup) {
console.log(
chalk.yellow("Temporary files preserved for debugging:"),
tempCleanup.path
);
}
}
}
module.exports = { run, runDownload };
/**
* Format bytes as human-readable text.
*
* @param bytes Number of bytes.
* @param si True to use metric (SI) units, aka powers of 1000. False to use
* binary (IEC), aka powers of 1024.
* @param dp Number of decimal places to display.
*
* @return Formatted string.
*/
function humanFileSize(bytes, si = true, dp = 1) {
const thresh = si ? 1000 : 1024;
if (Math.abs(bytes) < thresh) {
return `${bytes} B`;
}
const units = si
? ["kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
: ["KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"];
let u = -1;
const r = 10 ** dp;
do {
bytes /= thresh;
++u;
} while (
Math.round(Math.abs(bytes) * r) / r >= thresh &&
u < units.length - 1
);
return `${bytes.toFixed(dp)} ${units[u]}`;
}