import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; import { basename, dirname, join, resolve } from 'node:path'; const [planPath, lockPath, nodeModulesPath, outputPath] = process.argv.slice(2); if ([planPath, lockPath, nodeModulesPath, outputPath].some((value) => value === undefined)) { throw new Error('usage: generate-third-party-notices.mjs PLAN LOCK NODE_MODULES OUTPUT'); } const plan = readJson(planPath); const lock = readJson(lockPath); const cabalCache = fieldFromCommand('cabal', ['path'], 'cache-home'); const ghcLibDir = run('ghc', ['--print-libdir']).trim(); const ghcRoot = resolve(ghcLibDir, '../../..'); const ghcDocParent = join(ghcRoot, 'share', 'doc'); const ghcDocRootName = readdirSync(ghcDocParent).find((name) => name.endsWith(`-ghc-${compilerVersion(plan)}`)); if (ghcDocRootName === undefined) throw new Error('GHC package license directory not found'); const ghcDocRoot = join(ghcDocParent, ghcDocRootName); const haskellPackages = runtimePackages(plan['install-plan']) .map((pkg) => haskellNotice(pkg, cabalCache, ghcDocRoot, compilerVersion(plan))) .sort(comparePackage); const npmPackages = npmProductionPackages(lock) .map((pkg) => npmNotice(pkg, nodeModulesPath)) .sort(comparePackage); validateLicenses([...haskellPackages, ...npmPackages]); const sections = [ 'NUTHATCH THIRD-PARTY NOTICES', '', 'This file is generated by scripts/generate-third-party-notices.mjs.', 'It conservatively covers the Haskell dependency plan selected for exe:nuthatch', 'and every non-development package in the locked browser dependency closure.', '', 'The runtime image separately records its installed Debian packages in', '/usr/share/doc/nuthatch/debian-packages.txt. Their copyright and license', 'materials remain in the image under /usr/share/doc.', '', 'Container images used only by the test fixture are separate programs and are', 'not redistributed as part of Nuthatch. Their licenses remain their own.', '', inventory('HASKELL INVENTORY', haskellPackages), inventory('BROWSER PRODUCTION DEPENDENCY INVENTORY', npmPackages), 'LICENSE AND NOTICE TEXTS', ...haskellPackages.flatMap(renderNotice), ...npmPackages.flatMap(renderNotice) ]; writeFileSync(outputPath, `${sections.join('\n').replace(/\n+$/, '')}\n`); function readJson(path) { return JSON.parse(readFileSync(path, 'utf8')); } function run(command, args) { return execFileSync(command, args, { encoding: 'utf8' }); } function fieldFromCommand(command, args, field) { const prefix = `${field}:`; const line = run(command, args).split('\n').find((candidate) => candidate.startsWith(prefix)); if (line === undefined) throw new Error(`${field} not reported by ${command}`); return line.slice(prefix.length).trim(); } function compilerVersion(planValue) { const match = /^ghc-(.+)$/.exec(planValue['compiler-id']); if (match === null) throw new Error(`unsupported compiler id ${planValue['compiler-id']}`); return match[1]; } function uniquePackages(packages) { const unique = new Map(); for (const pkg of packages) { const key = `${pkg['pkg-name']}\u0000${pkg['pkg-version']}`; const existing = unique.get(key); if (existing !== undefined && existing.type !== pkg.type) { throw new Error(`inconsistent package source for ${pkg['pkg-name']}-${pkg['pkg-version']}`); } unique.set(key, pkg); } return [...unique.values()]; } function runtimePackages(packages) { const byId = new Map(packages.map((pkg) => [pkg.id, pkg])); const root = packages.find((pkg) => pkg['pkg-name'] === 'nuthatch' && pkg['component-name'] === 'exe:nuthatch'); if (root === undefined) throw new Error('exe:nuthatch not found in Cabal plan'); const selected = []; const visited = new Set(); const pending = [...(root.depends ?? [])]; while (pending.length > 0) { const id = pending.pop(); if (visited.has(id)) continue; visited.add(id); const pkg = byId.get(id); if (pkg === undefined) throw new Error(`Cabal dependency ${id} not found in plan`); pending.push(...(pkg.depends ?? [])); if (pkg['pkg-name'] !== 'nuthatch') selected.push(pkg); } return uniquePackages(selected); } function haskellNotice(pkg, cacheRoot, docRoot, ghcVersion) { const name = pkg['pkg-name']; const version = pkg['pkg-version']; if (pkg.type === 'configured') { const archive = join(cacheRoot, 'packages', 'hackage.haskell.org', name, version, `${name}-${version}.tar.gz`); if (!existsSync(archive)) { run('cabal', ['get', `${name}-${version}`, '--destdir=dist-newstyle/third-party/license-sources']); } if (!existsSync(archive)) throw new Error(`Hackage archive not found after fetch: ${archive}`); const entries = run('tar', ['-tzf', archive]).trim().split('\n'); const cabalEntry = entries.find((entry) => dirname(entry) === `${name}-${version}` && entry.endsWith('.cabal')); if (cabalEntry === undefined) throw new Error(`Cabal metadata not found in ${archive}`); const cabalText = run('tar', ['-xOzf', archive, cabalEntry]); const licenseValue = /^license:\s*(\S.*?)\s*$/im.exec(cabalText)?.[1]; if (licenseValue === undefined) throw new Error(`license field not found for ${name}-${version}`); const license = normalizeLicense(licenseValue); const documents = archiveDocuments(archive, entries, `${name}-${version}`); if (documents.length === 0) { documents.push({ name: 'PACKAGE METADATA', text: `The source archive supplies no standalone license or notice file.\nIts Cabal metadata declares ${license}.\n` }); } return { ecosystem: 'Haskell', name, version, license, documents }; } if (pkg.type === 'pre-existing') { const packageId = `${name}-${version}`; const licenseOutput = run('ghc-pkg', ['--global', 'field', packageId, 'license']); const licenseValue = /^license:\s*(\S.*?)\s*$/im.exec(licenseOutput)?.[1]; if (licenseValue === undefined) throw new Error(`GHC package license not found for ${packageId}`); const license = normalizeLicense(licenseValue); const packageLicense = join(docRoot, packageId, 'LICENSE'); const fallbackLicense = join(docRoot, `ghc-${ghcVersion}`, 'LICENSE'); const licensePath = existsSync(packageLicense) ? packageLicense : fallbackLicense; if (!existsSync(licensePath)) throw new Error(`license text not found for ${packageId}`); return { ecosystem: 'Haskell', name, version, license, documents: [{ name: 'LICENSE', text: readFileSync(licensePath, 'utf8') }] }; } throw new Error(`unsupported Cabal plan package type ${pkg.type}`); } function archiveDocuments(archive, entries, root) { const candidates = entries.filter((entry) => { const relative = entry.slice(root.length + 1); return !relative.includes('/') && /^(licen[cs]e|copying|notice)([._-].*)?$/i.test(relative); }); return candidates.sort().map((entry) => ({ name: basename(entry), text: run('tar', ['-xOzf', archive, entry]) })); } function npmProductionPackages(lockValue) { return Object.entries(lockValue.packages) .filter(([path, metadata]) => path !== '' && metadata.dev !== true) .map(([path, metadata]) => ({ path: path.replace(/^node_modules\//, ''), name: metadata.name ?? path.replace(/^node_modules\//, ''), version: metadata.version, license: metadata.license })); } function npmNotice(pkg, modulesRoot) { if (pkg.version === undefined || pkg.license === undefined) { throw new Error(`unclassified npm package ${pkg.name}`); } const packageRoot = join(modulesRoot, pkg.path); const candidates = readdirSync(packageRoot) .filter((name) => /^(licen[cs]e|copying|notice)([._-].*)?$/i.test(name)) .sort(); const documents = candidates.map((name) => ({ name, text: readFileSync(join(packageRoot, name), 'utf8') })); if (documents.length === 0) { documents.push({ name: 'PACKAGE METADATA', text: `The installed package supplies no standalone license or notice file.\nIts locked metadata declares ${pkg.license}.\n` }); } return { ecosystem: 'npm', name: pkg.name, version: pkg.version, license: pkg.license, documents }; } function comparePackage(left, right) { return compareText(left.name, right.name) || compareText(left.version, right.version); } function compareText(left, right) { return left < right ? -1 : left > right ? 1 : 0; } function normalizeLicense(license) { if (license === 'BSD2') return 'BSD-2-Clause'; if (license === 'BSD3') return 'BSD-3-Clause'; return license; } function validateLicenses(packages) { const allowed = new Set(['Apache-2.0', 'BSD-2-Clause', 'BSD-3-Clause', 'ISC', 'MIT']); const unsupported = packages.filter((pkg) => !allowed.has(pkg.license)); if (unsupported.length > 0) { throw new Error(`unsupported shipped licenses: ${unsupported.map((pkg) => `${pkg.name} (${pkg.license})`).join(', ')}`); } } function inventory(title, packages) { return [ title, ...packages.map((pkg) => `${pkg.name}\t${pkg.version}\t${pkg.license}`), '' ].join('\n'); } function renderNotice(pkg) { const separator = '='.repeat(79); return [ separator, `${pkg.ecosystem}: ${pkg.name} ${pkg.version} (${pkg.license})`, ...pkg.documents.flatMap((document) => [ `--- ${document.name} ---`, normalizeDocument(document.text), '' ]) ]; } function normalizeDocument(document) { return document .replace(/\r\n?/g, '\n') .split('\n') .map((line) => line.replace(/[ \t]+$/, '')) .join('\n') .replace(/\n+$/, ''); }