mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
8692e50603
* feat(licenses): tooling and initial data generation Adds the maintainer-only generator that produces src/data/licenses.json: - src-tauri/about.toml + about.hbs: cargo-about config + handlebars template for the Rust-side license enumeration - scripts/generate-licenses.mjs: orchestrator that runs cargo-about and license-checker-rseidelsohn (via npx, no devDep), merges the outputs into a single per-crate JSON with full license texts, and writes the result to src/data/licenses.json The script is invoked directly with `node scripts/generate-licenses.mjs` — no npm script wrapper on purpose, since adding one to package.json would trigger the nix-npm-deps-hash-sync workflow on every push. Initial generation covers 575 cargo crates + 71 npm packages (646 entries total, all with full license text bundled, ~1.4 MB JSON). * feat(licenses): Settings panel UI Adds a new Open Source Licenses section under Settings → System, sitting below Contributors. Components: - LicensesPanel.tsx: search input, curated highlight block of ~10 key dependencies (Tauri, React, rodio, symphonia, etc.), TanStack-Virtual list of all 600+ entries - LicenseTextModal.tsx: full-screen-ish modal showing the bundled license text plus name/version/license-id badges + repository link - licensesData.ts: lazy dynamic-import loader (Vite emits the JSON as a separate chunk, so the heavy ~1.4 MB payload is only loaded when the user actually opens the panel — no runtime fetch, the data is fixed into the build artifact) The panel registers itself in the Settings in-page search index under the System tab. * feat(licenses): i18n in 8 locales Adds the `licenses` namespace (title, intro, highlights, search placeholder, no-results, loading / load error, no-license-text, view-source, total line, generated-at) across en, de, fr, nl, zh, nb, ru, es. License names themselves (MIT, Apache-2.0, GPL-3.0, …) stay universal and are rendered as-is. * docs(release): document licenses regeneration step Adds a Step A.3 to the release SOP describing the maintainer-only `node scripts/generate-licenses.mjs` workflow, the cargo-about prerequisite, and explicitly notes why no npm script wrapper exists (would trigger the nix-npm-deps-hash-sync workflow).
253 lines
8.1 KiB
JavaScript
253 lines
8.1 KiB
JavaScript
#!/usr/bin/env node
|
|
// Generates src/data/licenses.json — the data backing Settings → System → Open Source Licenses.
|
|
//
|
|
// Maintainer-only operation: run before each release to refresh the bundled
|
|
// dependency list. Requires `cargo-about` installed globally
|
|
// (`cargo install cargo-about --features cli`). The npm-side license data is
|
|
// read via npx without persisting a devDependency, so package.json stays clean
|
|
// and the nix-npm-deps-hash-sync workflow is not triggered.
|
|
//
|
|
// Output lives in src/ (not public/) so Vite bundles it as a lazy JS chunk
|
|
// — no runtime fetch, the data is fixed into the build artifact.
|
|
//
|
|
// Output format (per entry):
|
|
// {
|
|
// name: string,
|
|
// version: string,
|
|
// source: 'npm' | 'cargo',
|
|
// licenses: string[], // SPDX ids, may be multi (dual-licensed)
|
|
// repository?: string,
|
|
// homepage?: string,
|
|
// description?: string,
|
|
// publisher?: string,
|
|
// licenseText?: string, // full license text, may be empty if not found
|
|
// }
|
|
//
|
|
// Usage: node scripts/generate-licenses.mjs
|
|
|
|
import { execSync, spawnSync } from 'node:child_process';
|
|
import { readFileSync, writeFileSync, existsSync, statSync, readdirSync, mkdirSync } from 'node:fs';
|
|
import { resolve, dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const REPO_ROOT = resolve(__dirname, '..');
|
|
const TAURI_DIR = join(REPO_ROOT, 'src-tauri');
|
|
const OUT_DIR = join(REPO_ROOT, 'src', 'data');
|
|
const OUT_PATH = join(OUT_DIR, 'licenses.json');
|
|
|
|
function die(msg) {
|
|
console.error(`\x1b[31m✗\x1b[0m ${msg}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
function step(msg) {
|
|
console.log(`\x1b[36m→\x1b[0m ${msg}`);
|
|
}
|
|
|
|
function ok(msg) {
|
|
console.log(`\x1b[32m✓\x1b[0m ${msg}`);
|
|
}
|
|
|
|
// ── Cargo side via cargo-about ──────────────────────────────────────────────
|
|
function checkCargoAbout() {
|
|
try {
|
|
execSync('cargo about --version', { stdio: 'ignore' });
|
|
} catch {
|
|
die(
|
|
'cargo-about not found. Install with:\n' +
|
|
' cargo install cargo-about --features cli',
|
|
);
|
|
}
|
|
}
|
|
|
|
function generateCargoLicenses() {
|
|
step('Running cargo-about (this resolves the full dependency graph and may take ~30s)…');
|
|
const result = spawnSync(
|
|
'cargo',
|
|
[
|
|
'about',
|
|
'generate',
|
|
'about.hbs',
|
|
'--config', 'about.toml',
|
|
'--manifest-path', 'Cargo.toml',
|
|
'--all-features',
|
|
],
|
|
{ cwd: TAURI_DIR, encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024 },
|
|
);
|
|
if (result.status !== 0) {
|
|
console.error(result.stderr);
|
|
die('cargo-about failed.');
|
|
}
|
|
const parsed = JSON.parse(result.stdout);
|
|
if (!parsed?.licenses) die('cargo-about output missing `licenses` key.');
|
|
|
|
// Flatten: pivot from license-grouped to crate-grouped.
|
|
const byCrate = new Map();
|
|
for (const lic of parsed.licenses) {
|
|
for (const u of lic.used_by ?? []) {
|
|
const key = `${u.name}@${u.version}`;
|
|
if (!byCrate.has(key)) {
|
|
byCrate.set(key, {
|
|
name: u.name,
|
|
version: u.version,
|
|
source: 'cargo',
|
|
licenses: [],
|
|
repository: u.repository || undefined,
|
|
description: u.description || undefined,
|
|
licenseText: '',
|
|
});
|
|
}
|
|
const entry = byCrate.get(key);
|
|
if (!entry.licenses.includes(lic.id)) entry.licenses.push(lic.id);
|
|
// Append text — a crate may have multiple licenses (dual). Keep them all,
|
|
// separated by a clear divider.
|
|
if (lic.text) {
|
|
if (entry.licenseText) {
|
|
entry.licenseText += `\n\n--- ${lic.id} ---\n\n${lic.text}`;
|
|
} else {
|
|
entry.licenseText = lic.text;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ok(`cargo: ${byCrate.size} crates`);
|
|
return [...byCrate.values()];
|
|
}
|
|
|
|
// ── npm side via license-checker-rseidelsohn (npx) ──────────────────────────
|
|
function readLicenseFile(path) {
|
|
if (!path || !existsSync(path)) return '';
|
|
try {
|
|
return readFileSync(path, 'utf-8');
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
// Try to find a notice/copyright file alongside the license file.
|
|
function findExtraLicenseTexts(packagePath) {
|
|
if (!packagePath || !existsSync(packagePath)) return '';
|
|
let extra = '';
|
|
try {
|
|
const entries = readdirSync(packagePath);
|
|
for (const name of entries) {
|
|
const upper = name.toUpperCase();
|
|
if (
|
|
upper === 'NOTICE' ||
|
|
upper === 'NOTICE.MD' ||
|
|
upper === 'NOTICE.TXT' ||
|
|
upper === 'COPYRIGHT' ||
|
|
upper === 'COPYRIGHT.MD' ||
|
|
upper === 'COPYRIGHT.TXT'
|
|
) {
|
|
const full = join(packagePath, name);
|
|
if (statSync(full).isFile()) {
|
|
const txt = readFileSync(full, 'utf-8').trim();
|
|
if (txt) extra += `\n\n--- ${name} ---\n\n${txt}`;
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
return extra;
|
|
}
|
|
|
|
function generateNpmLicenses() {
|
|
step('Running license-checker-rseidelsohn via npx (this enumerates node_modules)…');
|
|
const result = spawnSync(
|
|
'npx',
|
|
[
|
|
'--yes',
|
|
'license-checker-rseidelsohn@latest',
|
|
'--json',
|
|
'--production',
|
|
'--excludePrivatePackages',
|
|
],
|
|
{ cwd: REPO_ROOT, encoding: 'utf-8', maxBuffer: 256 * 1024 * 1024 },
|
|
);
|
|
if (result.status !== 0) {
|
|
console.error(result.stderr);
|
|
die('license-checker-rseidelsohn failed.');
|
|
}
|
|
const data = JSON.parse(result.stdout);
|
|
const out = [];
|
|
for (const [key, info] of Object.entries(data)) {
|
|
// key looks like `package-name@1.2.3` or `@scope/name@1.2.3`.
|
|
const at = key.lastIndexOf('@');
|
|
if (at <= 0) continue;
|
|
const name = key.slice(0, at);
|
|
const version = key.slice(at + 1);
|
|
const licenses = Array.isArray(info.licenses)
|
|
? info.licenses
|
|
: typeof info.licenses === 'string'
|
|
? [info.licenses]
|
|
: [];
|
|
const text =
|
|
readLicenseFile(info.licenseFile) +
|
|
findExtraLicenseTexts(info.path);
|
|
out.push({
|
|
name,
|
|
version,
|
|
source: 'npm',
|
|
licenses: licenses.map(String),
|
|
repository: info.repository || undefined,
|
|
homepage: info.url || undefined,
|
|
publisher: info.publisher || undefined,
|
|
description: undefined, // license-checker doesn't surface descriptions
|
|
licenseText: text.trim(),
|
|
});
|
|
}
|
|
ok(`npm: ${out.length} packages`);
|
|
return out;
|
|
}
|
|
|
|
// ── Self ────────────────────────────────────────────────────────────────────
|
|
function readSelfPackageJson() {
|
|
const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf-8'));
|
|
return {
|
|
name: pkg.name ?? 'psysonic',
|
|
version: pkg.version ?? '0.0.0',
|
|
};
|
|
}
|
|
|
|
// ── Main ────────────────────────────────────────────────────────────────────
|
|
function main() {
|
|
step(`Generating ${OUT_PATH.replace(REPO_ROOT + '/', '')}`);
|
|
mkdirSync(OUT_DIR, { recursive: true });
|
|
checkCargoAbout();
|
|
const cargoEntries = generateCargoLicenses();
|
|
const npmEntries = generateNpmLicenses();
|
|
|
|
const entries = [...cargoEntries, ...npmEntries];
|
|
// Stable sort: name, then version. Case-insensitive on name.
|
|
entries.sort((a, b) => {
|
|
const n = a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
|
if (n !== 0) return n;
|
|
return a.version.localeCompare(b.version);
|
|
});
|
|
|
|
const self = readSelfPackageJson();
|
|
const stats = {
|
|
npm: npmEntries.length,
|
|
cargo: cargoEntries.length,
|
|
total: entries.length,
|
|
withFullText: entries.filter((e) => e.licenseText && e.licenseText.length > 0).length,
|
|
};
|
|
|
|
const payload = {
|
|
generatedAt: new Date().toISOString(),
|
|
project: self,
|
|
stats,
|
|
entries,
|
|
};
|
|
|
|
writeFileSync(OUT_PATH, JSON.stringify(payload, null, 0) + '\n');
|
|
const sizeKb = (statSync(OUT_PATH).size / 1024).toFixed(1);
|
|
ok(`Wrote ${OUT_PATH} (${stats.total} entries, ${sizeKb} KB)`);
|
|
ok(` ${stats.cargo} cargo + ${stats.npm} npm; ${stats.withFullText} with full license text`);
|
|
}
|
|
|
|
main();
|