Files
donutbrowser/scripts/generate-licenses.mjs
T
2026-07-31 22:15:56 +04:00

235 lines
6.4 KiB
JavaScript

import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import parseSpdxExpression from "spdx-expression-parse";
import { XRAY_SOURCE_URL } from "../src-tauri/download-xray.mjs";
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = resolve(SCRIPT_DIR, "..");
const OUTPUT_PATH = resolve(PROJECT_ROOT, "src/generated/licenses.json");
const XRAY_SOURCE_OUTPUT_PATH = resolve(
PROJECT_ROOT,
"src/generated/xray-source.json",
);
const MAX_COMMAND_OUTPUT = 64 * 1024 * 1024;
export const RELEASE_TARGETS = [
"aarch64-apple-darwin",
"x86_64-apple-darwin",
"aarch64-unknown-linux-gnu",
"x86_64-unknown-linux-gnu",
"x86_64-pc-windows-msvc",
];
export const MANUAL_LICENSES = [
{
name: "Donut Browser",
license: "AGPL-3.0-only",
},
{
name: "Xray-core",
license: "MPL-2.0",
},
];
const LEGACY_LICENSE_EXPRESSIONS = new Map([
["Apache-2.0 / MIT", "Apache-2.0 OR MIT"],
["Apache-2.0/MIT", "Apache-2.0 OR MIT"],
["BSD-3-Clause/MIT", "BSD-3-Clause OR MIT"],
["MIT/Apache-2.0", "Apache-2.0 OR MIT"],
["MIT OR Apache-2.0", "Apache-2.0 OR MIT"],
["Unlicense/MIT", "MIT OR Unlicense"],
]);
const HOST_ONLY_PNPM_NATIVE_PREFIXES = [
"@img/sharp-",
"@img/sharp-libvips-",
"@next/swc-",
];
function validateLicenseExpression(expression) {
try {
parseSpdxExpression(expression);
} catch {
throw new Error(`Invalid SPDX expression: ${expression}`);
}
}
export function normalizeLicenseExpression(value) {
if (typeof value !== "string" || value.trim() === "") {
throw new Error("Every shipped dependency must declare a license");
}
const expression =
LEGACY_LICENSE_EXPRESSIONS.get(value.trim()) ?? value.trim();
validateLicenseExpression(expression);
return expression;
}
export function collectReachableRustLicenses(metadata) {
const root = metadata.resolve?.root;
if (!root) {
throw new Error("Cargo metadata did not identify the root package");
}
const packages = new Map(
metadata.packages.map((dependency) => [dependency.id, dependency]),
);
const nodes = new Map(metadata.resolve.nodes.map((node) => [node.id, node]));
const pending = [root];
const visited = new Set();
const result = [];
while (pending.length > 0) {
const packageId = pending.pop();
if (!packageId || visited.has(packageId)) continue;
visited.add(packageId);
if (packageId !== root) {
const dependency = packages.get(packageId);
if (!dependency) {
throw new Error(`Cargo metadata is missing package ${packageId}`);
}
result.push({
name: dependency.name,
license: dependency.license,
});
}
const node = nodes.get(packageId);
if (!node) continue;
for (const dependency of node.deps) {
const isRuntimeDependency = dependency.dep_kinds.some(
({ kind }) => kind === null,
);
if (isRuntimeDependency) pending.push(dependency.pkg);
}
}
return result;
}
export function collectPnpmLicenses(report) {
return Object.entries(report).flatMap(([groupLicense, dependencies]) =>
dependencies
.filter(
(dependency) =>
!HOST_ONLY_PNPM_NATIVE_PREFIXES.some((prefix) =>
dependency.name.startsWith(prefix),
),
)
.map((dependency) => ({
name: dependency.name,
license: dependency.license ?? groupLicense,
})),
);
}
export function prepareLicenseInventory(entries) {
const unique = new Map();
for (const entry of entries) {
if (typeof entry.name !== "string" || entry.name.trim() === "") {
throw new Error("Every shipped dependency must have a name");
}
const name = entry.name.trim();
const license = normalizeLicenseExpression(entry.license);
unique.set(`${name}\0${license}`, { name, license });
}
return [...unique.values()].sort((left, right) => {
const leftName = left.name.toLowerCase();
const rightName = right.name.toLowerCase();
if (leftName < rightName) return -1;
if (leftName > rightName) return 1;
if (left.name < right.name) return -1;
if (left.name > right.name) return 1;
return left.license < right.license
? -1
: Number(left.license > right.license);
});
}
function commandOutput(command, args) {
// pnpm ships only a `pnpm.cmd` batch shim on Windows, and Node refuses to
// spawn batch files without a shell (CVE-2024-27980), so `execFileSync`
// fails with EINVAL there. Every argument below is a literal from this file,
// so routing that one call through cmd.exe interpolates nothing.
const needsShell = process.platform === "win32" && command === "pnpm";
return execFileSync(needsShell ? "pnpm.cmd" : command, args, {
cwd: PROJECT_ROOT,
encoding: "utf8",
maxBuffer: MAX_COMMAND_OUTPUT,
shell: needsShell,
windowsHide: true,
});
}
function generateInventory() {
const entries = [...MANUAL_LICENSES];
const pnpmReport = JSON.parse(
commandOutput("pnpm", [
"--filter",
"donutbrowser",
"licenses",
"list",
"--prod",
"--json",
]),
);
entries.push(...collectPnpmLicenses(pnpmReport));
for (const target of RELEASE_TARGETS) {
const metadata = JSON.parse(
commandOutput("cargo", [
"metadata",
"--locked",
"--format-version",
"1",
"--filter-platform",
target,
"--manifest-path",
"src-tauri/Cargo.toml",
]),
);
entries.push(...collectReachableRustLicenses(metadata));
}
return prepareLicenseInventory(entries);
}
function main() {
const outputs = [
{
path: OUTPUT_PATH,
contents: `${JSON.stringify(generateInventory(), null, 2)}\n`,
},
{
path: XRAY_SOURCE_OUTPUT_PATH,
contents: `${JSON.stringify({ sourceUrl: XRAY_SOURCE_URL }, null, 2)}\n`,
},
];
if (process.argv.includes("--check")) {
for (const output of outputs) {
const current = readFileSync(output.path, "utf8");
if (current !== output.contents) {
throw new Error(`${output.path} is stale; run pnpm licenses:generate`);
}
}
return;
}
for (const output of outputs) {
mkdirSync(dirname(output.path), { recursive: true });
writeFileSync(output.path, output.contents);
}
}
const isDirectRun =
process.argv[1] &&
fileURLToPath(import.meta.url) === resolve(process.argv[1]);
if (isDirectRun) main();