scripts/licenses.js
Rama master · réplica de lectura del repositorio original.
// Third-party license credits for Settings → About → Licenses. Each build (the
// renderer and its voice-input worker, the desktop main process, the Docker
// server) reports the npm packages that actually ended up in its bundle, so
// the list is exactly what ships, not everything in package.json.
//
// The result is committed as src/lib/thirdPartyLicenses.json so `npm run dev`
// shows it too. Normal builds only check that file and fail when it no longer
// matches the bundle (a dependency was added, removed or updated), which keeps
// CI and releases from shipping stale credits. `npm run licenses`
// (scripts/update-licenses.js) reruns the builds with
// APPBLIPS_UPDATE_LICENSES=1 to rewrite it.
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
export const LICENSES_FILE = join(ROOT, 'src', 'lib', 'thirdPartyLicenses.json');
const PART_LABELS = { app: 'app', desktop: 'desktop main process', server: 'browser server' };
// Shipped, but not reached through the bundle's module graph.
const ALWAYS = {
app: ['tailwindcss'], // compiled into the stylesheet by PostCSS
desktop: ['electron'], // the runtime itself; external to the main bundle
server: [],
};
// Shipped, but not npm packages. `textFrom` borrows a package's license file
// that holds the same license's full text.
const EXTRAS = [
{
name: 'Whisper base (voice input model)',
author: 'OpenAI; ONNX conversion by onnx-community',
license: 'Apache-2.0',
homepage: 'https://huggingface.co/openai/whisper-base',
textFrom: '@huggingface/transformers/LICENSE',
},
];
// The package a bundled module belongs to: the last node_modules/<name>
// segment of its id, so nested copies resolve to their own version.
const packageDir = (id) => {
const path = id.replace(/^\0/, '').split('?')[0].replace(/\\/g, '/');
const match = path.match(/^(.*\/node_modules\/(?:@[^/]+\/)?[^/]+)/);
return match ? match[1] : null;
};
const LICENSE_FILE_RE = /^(licen[cs]e|copying|notice)([.\-_].*)?$/i;
const readLicenseText = (dir) => readdirSync(dir)
.filter((file) => LICENSE_FILE_RE.test(file))
// License before NOTICE, which Apache-2.0 packages must ship alongside it.
.sort((a, b) => /^notice/i.test(a) - /^notice/i.test(b) || a.localeCompare(b))
.map((file) => readFileSync(join(dir, file), 'utf8').trim())
.join('\n\n');
// package.json repository fields come in several git forms; the settings tab
// needs a web link, so anything that isn't one after normalizing is dropped.
const repoUrl = (repository) => {
const url = (typeof repository === 'string' ? repository : repository?.url || '')
.replace(/^git\+/, '')
.replace(/^(?:github:)?([\w.-]+\/[\w.-]+)$/, 'https://github.com/$1')
.replace(/^(?:ssh:\/\/)?git@([^:/]+)[:/]/, 'https://$1/')
.replace(/^git:\/\//, 'https://')
.replace(/\.git$/, '');
return /^https?:\/\//.test(url) ? url : '';
};
const describePackage = (dir) => {
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
const license = typeof pkg.license === 'string'
? pkg.license
: pkg.license?.type || (pkg.licenses || []).map((l) => l.type).join(' OR ') || 'Unknown';
return {
name: pkg.name,
version: pkg.version,
license,
author: typeof pkg.author === 'string' ? pkg.author.replace(/\s*[<(].*$/, '') : pkg.author?.name || '',
homepage: (/^https?:\/\//.test(pkg.homepage || '') && pkg.homepage) || repoUrl(pkg.repository),
text: readLicenseText(dir),
};
};
const keyOf = ({ name, version }) => `${name}@${version}`;
const readFile = () => (existsSync(LICENSES_FILE) ? JSON.parse(readFileSync(LICENSES_FILE, 'utf8')) : null);
// Checks (or, with APPBLIPS_UPDATE_LICENSES=1, rewrites) one part's packages.
export function syncLicenses(part, dirs) {
const fresh = [...new Set([...dirs, ...ALWAYS[part].map((name) => join(ROOT, 'node_modules', name))])]
.map(describePackage);
const keys = [...new Set(fresh.map(keyOf))].sort();
const current = readFile();
if (process.env.APPBLIPS_UPDATE_LICENSES !== '1') {
const listed = current?.parts?.[part] || [];
const added = keys.filter((key) => !listed.includes(key));
const removed = listed.filter((key) => !keys.includes(key));
if (added.length || removed.length) {
throw new Error(
`The third-party license list for the ${PART_LABELS[part]} is out of date`
+ `${added.length ? `\n new: ${added.join(', ')}` : ''}${removed.length ? `\n gone: ${removed.join(', ')}` : ''}`
+ `\nRun "npm run licenses" and commit ${relative(ROOT, LICENSES_FILE)}.`,
);
}
return;
}
const parts = { ...current?.parts, [part]: keys };
const wanted = new Set(Object.values(parts).flat());
const byKey = new Map((current?.packages || []).map((entry) => [keyOf(entry), entry]));
for (const entry of fresh) byKey.set(keyOf(entry), entry);
const packages = [...byKey.values()]
.filter((entry) => wanted.has(keyOf(entry)))
.sort((a, b) => a.name.localeCompare(b.name) || a.version.localeCompare(b.version));
const extras = EXTRAS.map(({ textFrom, ...extra }) => ({
...extra,
text: readFileSync(join(ROOT, 'node_modules', textFrom), 'utf8').trim(),
}));
const appblips = readFileSync(join(ROOT, 'LICENSE'), 'utf8').trim();
writeFileSync(LICENSES_FILE, `${JSON.stringify({ appblips, parts, packages, extras }, null, 2)}\n`);
console.log(`Updated ${relative(ROOT, LICENSES_FILE)} (${PART_LABELS[part]}: ${keys.length} packages)`);
}
// A Rollup/Rolldown/Vite plugin that collects the bundled packages into
// `collected` and, unless `finish` is false (Vite's worker builds, which feed
// the app's collection), syncs them when the bundle closes.
export function licensePlugin(part, { collected = new Set(), finish = true } = {}) {
return {
name: 'appblips-licenses',
apply: 'build',
generateBundle(_options, bundle) {
for (const file of Object.values(bundle)) {
for (const id of file.moduleIds || []) {
const dir = packageDir(id);
if (dir) collected.add(dir);
}
}
},
closeBundle() {
if (finish) syncLicenses(part, collected);
},
};
}