mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
166 lines
6.5 KiB
JavaScript
166 lines
6.5 KiB
JavaScript
#!/usr/bin/env node
|
|||
|
|
|
||
|
|
import { execFileSync } from "node:child_process";
|
||
|
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||
|
|
import path from "node:path";
|
||
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||
|
|
|
||
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||
|
|
const policyPath = path.join(root, "config/production-license-policy.json");
|
||
|
|
const noticesPath = path.join(root, "THIRD_PARTY_NOTICES.md");
|
||
|
|
|
||
|
|
function packageLabels(entries) {
|
||
|
|
return entries
|
||
|
|
.flatMap((entry) => {
|
||
|
|
const versions = Array.isArray(entry.versions)
|
||
|
|
? [...entry.versions].map(String).sort().join(",")
|
||
|
|
: "unknown";
|
||
|
|
return `${String(entry.name ?? "unnamed")}@${versions}`;
|
||
|
|
})
|
||
|
|
.sort();
|
||
|
|
}
|
||
|
|
|
||
|
|
export function validateInventory(inventory, policy) {
|
||
|
|
const allowed = new Set(policy.allowedExpressions ?? []);
|
||
|
|
const denied = new Set(policy.deniedExpressions ?? []);
|
||
|
|
const violations = [];
|
||
|
|
|
||
|
|
for (const expression of Object.keys(inventory).sort()) {
|
||
|
|
const entries = Array.isArray(inventory[expression]) ? inventory[expression] : [];
|
||
|
|
const packages = packageLabels(entries).join(", ");
|
||
|
|
const declaredLicenses = new Set(entries.map((entry) => entry.license));
|
||
|
|
if (declaredLicenses.size !== 1 || !declaredLicenses.has(expression)) {
|
||
|
|
violations.push(`inventory group ${expression} has inconsistent package license metadata`);
|
||
|
|
}
|
||
|
|
if (/^(unknown|unlicensed)$/i.test(expression)) {
|
||
|
|
violations.push(`unknown license expression ${expression}: ${packages}`);
|
||
|
|
} else if (denied.has(expression)) {
|
||
|
|
violations.push(`denied license expression ${expression}: ${packages}`);
|
||
|
|
} else if (!allowed.has(expression)) {
|
||
|
|
violations.push(`unapproved license expression ${expression}: ${packages}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const expression of denied) {
|
||
|
|
if (allowed.has(expression)) {
|
||
|
|
violations.push(`policy expression is both allowed and denied: ${expression}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return violations.sort();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Native bindings ship one package per platform and arch, so `pnpm licenses
|
||
|
|
// list` returns whichever set the current machine installed. Rendering those
|
||
|
|
// verbatim makes the notices file describe the developer's laptop: it is
|
||
|
|
// regenerated on macOS, then the Linux CI runner resolves the x64 bindings
|
||
|
|
// instead and fails the very check that produced it.
|
||
|
|
//
|
||
|
|
// Collapse each family to its base name for the notices. The policy check above
|
||
|
|
// still sees every package the current platform installed, so an unacceptable
|
||
|
|
// license in a binding is caught wherever it is installed; only the attribution
|
||
|
|
// list is normalized, and the upstream project is still credited once.
|
||
|
|
// Three shapes in the wild: a suffix on the package name
|
||
|
|
// (@img/sharp-darwin-arm64), the libc glued to the OS (@img/sharp-linuxmusl-arm64),
|
||
|
|
// and the whole unscoped name (@esbuild/darwin-arm64), which collapses to the
|
||
|
|
// scope alone.
|
||
|
|
const PLATFORM_SUFFIX =
|
||
|
|
/[-/](?:darwin|linux(?:musl)?|win32|freebsd|openbsd|android|sunos)(?:-(?:x64|arm64|arm|ia32|ppc64|s390x|riscv64|loong64))?(?:-(?:musl|gnu|gnueabihf|msvc))?$/;
|
||
|
|
|
||
|
|
// Packages that only exist on one platform, rather than shipping a build per
|
||
|
|
// platform. fsevents is macOS-only by design and simply absent on Linux, so it
|
||
|
|
// cannot be normalized into a shared family and has to be named.
|
||
|
|
const PLATFORM_ONLY_PACKAGES = new Set(["fsevents"]);
|
||
|
|
|
||
|
|
function platformFamily(name) {
|
||
|
|
return String(name).replace(PLATFORM_SUFFIX, "");
|
||
|
|
}
|
||
|
|
|
||
|
|
export function renderNotices(inventory) {
|
||
|
|
const lines = [
|
||
|
|
"# Third-Party Production Node Dependency Notices",
|
||
|
|
"",
|
||
|
|
"This file is generated from the frozen pnpm production dependency graph.",
|
||
|
|
"This inventory covers Node packages only; exact release artifacts publish separate full SBOMs.",
|
||
|
|
"Run `pnpm check:production-node-licenses -- --write-notices` after an intentional dependency change.",
|
||
|
|
"Package authors retain all rights granted by their respective licenses.",
|
||
|
|
"",
|
||
|
|
];
|
||
|
|
|
||
|
|
for (const expression of Object.keys(inventory).sort()) {
|
||
|
|
lines.push(`## ${expression}`, "");
|
||
|
|
const families = new Map();
|
||
|
|
for (const entry of inventory[expression]) {
|
||
|
|
if (PLATFORM_ONLY_PACKAGES.has(entry.name)) continue;
|
||
|
|
const name = platformFamily(entry.name);
|
||
|
|
const family = families.get(name) ?? { name, versions: new Set(), homepage: entry.homepage };
|
||
|
|
for (const version of entry.versions) family.versions.add(String(version));
|
||
|
|
family.homepage ??= entry.homepage;
|
||
|
|
families.set(name, family);
|
||
|
|
}
|
||
|
|
const entries = [...families.values()].sort((left, right) =>
|
||
|
|
left.name.localeCompare(right.name),
|
||
|
|
);
|
||
|
|
for (const entry of entries) {
|
||
|
|
const versions = [...entry.versions].sort().join(",");
|
||
|
|
const label = `${entry.name}@${versions}`;
|
||
|
|
lines.push(entry.homepage ? `- [${label}](${entry.homepage})` : `- ${label}`);
|
||
|
|
}
|
||
|
|
lines.push("");
|
||
|
|
}
|
||
|
|
return `${lines.join("\n").trimEnd()}\n`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function readPolicy() {
|
||
|
|
const policy = JSON.parse(readFileSync(policyPath, "utf8"));
|
||
|
|
if (
|
||
|
|
policy.schemaVersion !== 1 ||
|
||
|
|
!Array.isArray(policy.allowedExpressions) ||
|
||
|
|
!Array.isArray(policy.deniedExpressions)
|
||
|
|
) {
|
||
|
|
throw new Error("production license policy has an unsupported schema");
|
||
|
|
}
|
||
|
|
return policy;
|
||
|
|
}
|
||
|
|
|
||
|
|
function readInventory() {
|
||
|
|
return JSON.parse(
|
||
|
|
execFileSync("pnpm", ["licenses", "list", "--prod", "--json"], {
|
||
|
|
cwd: root,
|
||
|
|
encoding: "utf8",
|
||
|
|
maxBuffer: 32 * 1024 * 1024,
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function main() {
|
||
|
|
const policy = readPolicy();
|
||
|
|
const inventory = readInventory();
|
||
|
|
const violations = validateInventory(inventory, policy);
|
||
|
|
if (violations.length > 0) {
|
||
|
|
for (const violation of violations) console.error(`ERROR: ${violation}`);
|
||
|
|
process.exitCode = 1;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const expectedNotices = renderNotices(inventory);
|
||
|
|
if (process.argv.includes("--write-notices")) {
|
||
|
|
writeFileSync(noticesPath, expectedNotices);
|
||
|
|
console.log(`Updated ${path.relative(root, noticesPath)}`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!existsSync(noticesPath) || readFileSync(noticesPath, "utf8") !== expectedNotices) {
|
||
|
|
console.error(
|
||
|
|
"ERROR: THIRD_PARTY_NOTICES.md is stale; review the dependency change, then run pnpm check:production-node-licenses -- --write-notices",
|
||
|
|
);
|
||
|
|
process.exitCode = 1;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const packageCount = Object.values(inventory).reduce((sum, entries) => sum + entries.length, 0);
|
||
|
|
console.log(
|
||
|
|
`Production Node license policy passed: ${packageCount} packages across ${Object.keys(inventory).length} expressions`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) main();
|