Files
Jungfraujoch/frontend/scripts/generate-third-party-licenses.mjs
T
leonarski_fandClaude Opus 4.8 e5034d0a2c licenses: add third-party notices, attribution texts, and viewer license window
Acknowledge all bundled third-party software and satisfy attribution/notice
requirements, while keeping it maintainable:

- THIRD_PARTY_NOTICES.md: human-readable manifest (component, copyright, SPDX
  license, link) for fetched, vendored, and runtime/SDK dependencies.
- licenses/: verbatim license texts; COLLECT.sh regenerates them from the
  build trees and system SDK locations.
- Bundle the verbatim Qt LGPL-3.0 text and the CUDA Toolkit 12.8 EULA.
- frontend: self-contained npm attribution generator (`npm run licenses` ->
  dist/THIRD_PARTY_LICENSES.txt), wired into the frontend build target.
- Install LICENSE + notices + licenses/ into share/doc/jfjoch for every
  packaged component.
- viewer: Help > "Third-party Licenses" window (QTextBrowser) showing a
  generated, self-contained HTML built from licenses/.
- docs/SOFTWARE.md: drop the stale hand-kept dependency lists; point at the
  manifest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:35:50 +02:00

122 lines
4.4 KiB
JavaScript

// Generate frontend/dist/THIRD_PARTY_LICENSES.txt: the third-party attribution file for the
// npm packages bundled into the built frontend.
//
// Self-contained (no extra dependency): it asks npm for the *production* dependency tree, then
// reads each package's own license file from node_modules and concatenates them. Run via
// `npm run licenses` (also invoked by the CMake `frontend` build target).
import { execFileSync } from "node:child_process";
import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const frontendDir = join(dirname(fileURLToPath(import.meta.url)), "..");
const nodeModules = join(frontendDir, "node_modules");
const outFile = join(frontendDir, "dist", "THIRD_PARTY_LICENSES.txt");
const LICENSE_FILE = /^(licen[sc]e|copying|notice)([.-].*)?$/i;
// Set of "name@version" strings that actually ship in production (excludes devDependencies).
function productionPackages() {
const json = execFileSync("npm", ["ls", "--omit=dev", "--all", "--json"], {
cwd: frontendDir,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
const set = new Set();
const walk = (deps) => {
for (const [name, info] of Object.entries(deps || {})) {
if (info.version) set.add(`${name}@${info.version}`);
walk(info.dependencies);
}
};
walk(JSON.parse(json).dependencies);
return set;
}
// Map "name@version" -> package directory, by scanning every node_modules in the install tree.
function installedPackages() {
const map = new Map();
const scan = (nm) => {
if (!existsSync(nm)) return;
for (const entry of readdirSync(nm)) {
if (entry.startsWith(".")) continue;
if (entry.startsWith("@")) {
for (const sub of readdirSync(join(nm, entry))) record(join(nm, entry, sub));
} else {
record(join(nm, entry));
}
}
};
const record = (dir) => {
const pkgJson = join(dir, "package.json");
if (!existsSync(pkgJson)) return;
try {
const pkg = JSON.parse(readFileSync(pkgJson, "utf8"));
if (pkg.name && pkg.version) map.set(`${pkg.name}@${pkg.version}`, { dir, pkg });
} catch { /* ignore unparseable package.json */ }
scan(join(dir, "node_modules"));
};
scan(nodeModules);
return map;
}
function findLicenseText(dir) {
const file = readdirSync(dir).find((f) => LICENSE_FILE.test(f));
if (!file) return null;
try {
return readFileSync(join(dir, file), "utf8").trimEnd();
} catch {
return null;
}
}
function licenseId(pkg) {
if (typeof pkg.license === "string") return pkg.license;
if (pkg.license?.type) return pkg.license.type;
if (Array.isArray(pkg.licenses)) return pkg.licenses.map((l) => l.type || l).join(" OR ");
return "UNKNOWN";
}
function repoUrl(pkg) {
const r = pkg.repository;
if (typeof r === "string") return r;
if (r?.url) return r.url.replace(/^git\+/, "").replace(/\.git$/, "");
return pkg.homepage || "";
}
const prod = productionPackages();
const installed = installedPackages();
const blocks = [];
const missing = [];
for (const id of [...prod].sort((a, b) => a.localeCompare(b))) {
const found = installed.get(id);
if (!found) continue; // present in tree report but not on disk (e.g. bundled) — skip
const { dir, pkg } = found;
const text = findLicenseText(dir);
if (!text) missing.push(`${id} (${licenseId(pkg)})`);
const url = repoUrl(pkg);
blocks.push(
`${"=".repeat(80)}\n` +
`${id}${licenseId(pkg)}\n` +
(url ? `${url}\n` : "") +
`${"=".repeat(80)}\n\n` +
(text || `License: ${licenseId(pkg)} (no license file shipped in this package)`),
);
}
const header =
"Third-party software bundled in the Jungfraujoch frontend\n" +
"=========================================================\n\n" +
"The Jungfraujoch frontend is licensed under GPL-3.0. It bundles the following npm packages\n" +
"(production dependencies). Each package's license and notice text follows.\n\n" +
`Generated by scripts/generate-third-party-licenses.mjs — ${prod.size} packages.\n` +
(missing.length
? `\nPackages without a bundled license file (license field shown above):\n ${missing.join("\n ")}\n`
: "");
mkdirSync(dirname(outFile), { recursive: true });
writeFileSync(outFile, header + "\n" + blocks.join("\n\n") + "\n", "utf8");
console.log(`Wrote ${outFile} (${blocks.length} packages, ${missing.length} without license file).`);