import { readdir, readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { BundledPackage } from "./packages.js"; /** * `THIRD_PARTY_NOTICES` (ADR 0049): the licence and notice text that redistributing a module * package requires, generated from what was actually bundled. * * - **Discovery.** Every source file in every bundle (the backend, and each browser artifact) is * traced to the package it belongs to. Packages installed under `node_modules` are third-party * software and are credited here. The Ekklesia platform's own packages, and the module itself, * are not third-party software: the platform packages appear in `ekklesia.package.json` under * `incorporates`. * - **Content.** For each third-party package: its name, version, declared licence, where in the * package it is used, and the complete text of every licence, copying, copyright, and notice file * it ships at its root, plus the file a `SEE LICENSE IN` declaration names. * - **Duplicates.** A package bundled into several artifacts is credited once, listing every * artifact kind it is used in. Different packages are never merged, even when their texts match, * so each component keeps its own notice. * - **Gaps fail.** A package that declares no licence, or whose licence cannot be established, * stops packaging. When a package ships no licence file, the standard text of its declared licence * is supplied only if that licence's standard text is complete without project-specific lines * (Apache-2.0). An MIT, ISC, or BSD package without its file is refused, because its copyright line * exists only in that file. * * Everything is sorted, and line endings are normalized, so the file is byte-identical for identical * inputs. */ export type NoticeErrorCode = "MODULE_PACK_LICENCE_TEXT_MISSING" | "MODULE_PACK_LICENCE_UNKNOWN"; export class NoticeError extends Error { readonly code: NoticeErrorCode; readonly component: string; constructor(code: NoticeErrorCode, component: string) { super(code); this.name = "NoticeError"; this.code = code; this.component = component; } } export type NoticeUse = "backend" | "browser"; export interface NoticeComponent { readonly bundled: BundledPackage; readonly uses: ReadonlySet; } /** Licences whose standard text is complete without any project-specific line, so supplying it * for a package that ships none is faithful. Checked into this package (`licenses/`). */ const STANDARD_TEXTS: Readonly> = Object.freeze({ "Apache-2.0": "Apache-2.0.txt", }); const LICENCE_FILE = /^(?:licen[cs]e(?:[-._][a-z0-9.-]+)?|copying(?:[-._][a-z0-9.-]+)?|notice(?:[-._][a-z0-9.-]+)?|copyright(?:[-._][a-z0-9.-]+)?)$/iu; const RULE = "-".repeat(80); function licenceOf(bundled: BundledPackage): string | undefined { const declared = bundled.manifest.license; if (typeof declared === "string" && declared.trim() !== "") { return declared.trim(); } if (typeof declared === "object" && declared !== null && "type" in declared) { const type = (declared as { type?: unknown }).type; return typeof type === "string" && type.trim() !== "" ? type.trim() : undefined; } return undefined; } function normalized(text: string): string { return `${text.replaceAll("\r\n", "\n").replaceAll("\r", "\n").replace(/\s+$/u, "")}\n`; } function compare(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; } async function licenceTexts( bundled: BundledPackage, licence: string, ): Promise { const names = (await readdir(bundled.root, { withFileTypes: true })) .filter((entry) => entry.isFile() && LICENCE_FILE.test(entry.name)) .map((entry) => entry.name) .sort(compare); const seeFile = /^SEE LICEN[CS]E IN (.+)$/iu.exec(licence)?.[1]?.trim(); if (seeFile && !names.includes(seeFile)) { const resolved = path.resolve(bundled.root, seeFile); if (resolved.startsWith(`${bundled.root}${path.sep}`)) { names.push(seeFile); } } const texts: { title: string; text: string }[] = []; for (const name of names) { texts.push({ text: normalized(await readFile(path.join(bundled.root, name), "utf8")), title: name, }); } return texts; } const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); async function standardText(licence: string): Promise { const file = STANDARD_TEXTS[licence]; return file === undefined ? undefined : normalized(await readFile(path.join(packageRoot, "licenses", file), "utf8")); } export async function thirdPartyNotices( components: ReadonlyMap, ): Promise { const ordered = [...components.values()].sort( (left, right) => compare(left.bundled.name, right.bundled.name) || compare(left.bundled.version, right.bundled.version), ); const sections: string[] = []; for (const { bundled, uses } of ordered) { const component = `${bundled.name}@${bundled.version}`; const licence = licenceOf(bundled); if (licence === undefined || /^UNLICEN[CS]ED$/iu.test(licence)) { throw new NoticeError("MODULE_PACK_LICENCE_UNKNOWN", component); } let texts = await licenceTexts(bundled, licence); // A NOTICE file alone is not the licence: the licence text must still come from somewhere. if (!texts.some((text) => !/^notice/iu.test(text.title))) { const standard = await standardText(licence); if (standard === undefined) { throw new NoticeError("MODULE_PACK_LICENCE_TEXT_MISSING", component); } texts = [ ...texts, { text: `The package ships no licence file. This is the standard text of the licence it declares (${licence}).\n\n${standard}`, title: `Standard ${licence} text`, }, ]; } sections.push( [ RULE, `${bundled.name} ${bundled.version}`, `License: ${licence}`, `Used in: ${[...uses].sort(compare).join(", ")}`, RULE, "", ...texts.flatMap((text) => [`[${text.title}]`, "", text.text]), ].join("\n"), ); } const header = [ "THIRD-PARTY SOFTWARE NOTICES", "", "This module package includes the third-party software listed below. Each entry names the", "component, its version, its declared licence, where in the package it is used, and the licence", "and notice text that came with it.", "", "The Ekklesia platform's own packages are not third-party software. They are listed in", 'ekklesia.package.json under "incorporates".', "", ordered.length === 0 ? "This module package includes no third-party software." : `${ordered.length} ${ordered.length === 1 ? "component" : "components"}.`, "", ].join("\n"); return `${[header, ...sections].join("\n").replace(/\n+$/u, "")}\n`; }