Files
kandev/apps/web/scripts/generate-licenses.ts
T
José Almeida 9c16d337a2 feat(web): add global app status surface (#1869)
* feat(web): add app status bar

* feat(web): add app status bar visibility toggle

* feat(web): persist status bar item order

* fix(web): refine app status bar behavior

* fix(web): polish app status bar presentation

* test(web): split plugin slot coverage groups

* fix(web): address status surface review feedback

* fix(web): stabilize status bar overlay tests
2026-07-23 12:28:54 +01:00

480 lines
16 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Pre-build / on-demand script that compiles a single JSON manifest of every
* third-party OSS dependency (npm + Go) shipped with kandev, along with its
* resolved license. The output is committed and read statically by the future
* `/settings/system/licenses` page — zero runtime cost, no network access.
*
* Output: apps/web/generated/licenses.json
*
* npm side -> `pnpm licenses list --json --prod` over the workspace.
* Go side -> `go-licenses report ./...` from `apps/backend/` with a custom
* template so we can read each module's local LICENSE file.
*
* Run with: `pnpm --filter @kandev/web licenses:gen`
*/
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
type Ecosystem = "npm" | "go" | "source";
interface LicenseEntry {
name: string;
version: string;
license: string;
repository?: string;
license_text?: string;
stale?: boolean;
ecosystem: Ecosystem;
}
const __dirname = dirname(fileURLToPath(import.meta.url));
const WEB_ROOT = join(__dirname, "..");
const APPS_ROOT = join(WEB_ROOT, "..");
const REPO_ROOT = join(APPS_ROOT, "..");
const BACKEND_ROOT = join(REPO_ROOT, "apps", "backend");
const OUTPUT_DIR = join(WEB_ROOT, "generated");
const OUTPUT_FILE = join(OUTPUT_DIR, "licenses.json");
const MAX_LICENSE_TEXT_BYTES = 64 * 1024; // 64 KiB guard
// SPDX-ish identifiers that mean "we don't know" — normalize them.
const UNKNOWN_LICENSE = "UNKNOWN";
const THIRD_PARTY_SOURCE_NOTICES: LicenseEntry[] = [
{
name: "Orca status-bar reference",
version: "d9d939a33b5858495ffb33489a952f1ac9293610",
license: "MIT",
repository: "https://github.com/stablyai/orca",
license_text: `MIT License
Copyright (c) 2026 Lovecast Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
`,
ecosystem: "source",
},
];
function readSafe(path: string): string | undefined {
try {
const buf = readFileSync(path);
if (buf.length > MAX_LICENSE_TEXT_BYTES) {
return buf.subarray(0, MAX_LICENSE_TEXT_BYTES).toString("utf-8");
}
return buf.toString("utf-8");
} catch {
return undefined;
}
}
function normalizeLicense(raw: string | undefined): string {
if (!raw) return UNKNOWN_LICENSE;
const trimmed = raw.trim();
if (!trimmed || trimmed.toLowerCase() === "unknown") return UNKNOWN_LICENSE;
return trimmed;
}
function repoFromPackageJson(pkgPath: string): string | undefined {
const data = readSafe(join(pkgPath, "package.json"));
if (!data) return undefined;
try {
const json = JSON.parse(data) as {
repository?: string | { url?: string };
homepage?: string;
};
const repo = json.repository;
if (typeof repo === "string") return repo;
if (repo && typeof repo.url === "string") {
return repo.url.replace(/^git\+/, "").replace(/\.git$/, "");
}
return json.homepage;
} catch {
return undefined;
}
}
function findLicenseFile(pkgPath: string): string | undefined {
const candidates = [
"LICENSE",
"LICENSE.md",
"LICENSE.txt",
"LICENSE-MIT",
"LICENSE-APACHE",
"License",
"license",
"license.md",
"license.txt",
"COPYING",
"COPYING.txt",
];
for (const name of candidates) {
const path = join(pkgPath, name);
if (existsSync(path)) return path;
}
return undefined;
}
function readLicenseText(pkgPath: string | undefined): string | undefined {
if (!pkgPath) return undefined;
const licensePath = findLicenseFile(pkgPath);
if (!licensePath) return undefined;
return readSafe(licensePath);
}
interface PnpmPackage {
name: string;
versions?: string[];
version?: string;
paths?: string[];
path?: string;
license?: string;
homepage?: string;
}
type PnpmLicensesOutput = Record<string, PnpmPackage[]>;
function collectNpmEntries(): LicenseEntry[] {
let raw: string;
try {
raw = execFileSync("pnpm", ["licenses", "list", "--json", "--prod"], {
cwd: APPS_ROOT,
encoding: "utf-8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "inherit"],
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
process.stderr.write(
`[licenses] warning: failed to enumerate npm dependencies via pnpm: ${message}\n`,
);
return [];
}
let parsed: PnpmLicensesOutput;
try {
parsed = JSON.parse(raw) as PnpmLicensesOutput;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
process.stderr.write(`[licenses] warning: invalid JSON from pnpm licenses: ${message}\n`);
return [];
}
const entries: LicenseEntry[] = [];
for (const [licenseId, pkgs] of Object.entries(parsed)) {
for (const pkg of pkgs) {
entries.push(...flattenPnpmPackage(pkg, licenseId));
}
}
return entries;
}
function flattenPnpmPackage(pkg: PnpmPackage, fallbackLicense: string): LicenseEntry[] {
const versions = pkg.versions ?? (pkg.version ? [pkg.version] : []);
const paths = pkg.paths ?? (pkg.path ? [pkg.path] : []);
const license = normalizeLicense(pkg.license ?? fallbackLicense);
// pnpm collapses multiple versions of the same package into one record,
// but the path array is aligned by index — pair them.
const results: LicenseEntry[] = [];
for (let i = 0; i < versions.length; i++) {
const version = versions[i];
const pkgPath = paths[i] ?? paths[0];
const repo = pkgPath ? repoFromPackageJson(pkgPath) : undefined;
const licenseText = readLicenseText(pkgPath);
results.push({
name: pkg.name,
version,
license,
...(repo ? { repository: repo } : {}),
...(licenseText ? { license_text: licenseText } : {}),
ecosystem: "npm",
});
}
return results;
}
interface GoRow {
pkg: string;
licensePath: string;
licenseURL: string;
licenseName: string;
}
function collectGoEntries(): LicenseEntry[] {
const probe = spawnSync("go-licenses", ["--help"], { stdio: "ignore" });
if (probe.error || probe.status !== 0) {
process.stderr.write(
"[licenses] error: `go-licenses` not found on PATH.\n" +
" Install it with: go install github.com/google/go-licenses@latest\n" +
" Then re-run: pnpm --filter @kandev/web licenses:gen\n",
);
throw new Error("go-licenses is required");
}
const template = `{{range .}}{{.Name}}|||{{with .LicensePath}}{{.}}{{end}}|||{{with .LicenseURL}}{{.}}{{end}}|||{{with .LicenseName}}{{.}}{{end}}
{{end}}`;
const tmplPath = join(tmpdir(), `kandev-go-licenses-${process.pid}.tpl`);
writeFileSync(tmplPath, template);
let raw: string;
try {
raw = execFileSync("go-licenses", ["report", "./...", "--template", tmplPath], {
cwd: BACKEND_ROOT,
encoding: "utf-8",
maxBuffer: 64 * 1024 * 1024,
// Discard noisy "Failed to find license" stderr — those are kandev's own
// packages, not third-party. The CSV body still contains every resolved dep.
stdio: ["ignore", "pipe", "pipe"],
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
process.stderr.write(`[licenses] warning: go-licenses failed: ${message}\n`);
const recovery = recoverGoEntriesAfterReportFailure(execStdout(err), readExistingManifest());
if (recovery?.source === "go-licenses-stdout") {
process.stderr.write(
`[licenses] warning: recovered ${recovery.entries.length} Go entries from go-licenses stdout.\n`,
);
return recovery.entries;
}
if (recovery?.source === "existing-manifest") {
process.stderr.write(
`[licenses] warning: reusing ${recovery.entries.length} stale Go entries from the existing manifest.\n`,
);
return recovery.entries;
}
throw new Error("go-licenses failed and no existing Go entries are available");
} finally {
rmSync(tmplPath, { force: true });
}
return goEntriesFromReport(raw);
}
type GoEntryRecoverySource = "go-licenses-stdout" | "existing-manifest";
interface GoEntryRecovery {
entries: LicenseEntry[];
source: GoEntryRecoverySource;
}
export function recoverGoEntriesAfterReportFailure(
stdout: string,
existingManifestRaw?: string,
): GoEntryRecovery | null {
const recoveredEntries = goEntriesFromReport(stdout);
if (recoveredEntries.length > 0) {
return { entries: recoveredEntries, source: "go-licenses-stdout" };
}
const existingEntries = existingManifestRaw
? markStaleGoEntries(readGoEntriesFromManifest(existingManifestRaw))
: [];
if (existingEntries.length > 0) {
return { entries: existingEntries, source: "existing-manifest" };
}
return null;
}
function goEntriesFromReport(raw: string): LicenseEntry[] {
return parseGoRows(raw).filter(isThirdPartyGoRow).map(toGoEntry);
}
function execStdout(err: unknown): string {
const stdout = (err as { stdout?: Buffer | string }).stdout;
if (typeof stdout === "string") return stdout;
if (Buffer.isBuffer(stdout)) return stdout.toString("utf-8");
return "";
}
function readExistingManifest(): string | undefined {
try {
return readFileSync(OUTPUT_FILE, "utf-8");
} catch {
return undefined;
}
}
export function readGoEntriesFromManifest(raw: string): LicenseEntry[] {
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter(isLicenseEntry).filter((entry) => entry.ecosystem === "go");
} catch {
return [];
}
}
function isLicenseEntry(value: unknown): value is LicenseEntry {
if (!value || typeof value !== "object") return false;
const entry = value as Partial<Record<keyof LicenseEntry, unknown>>;
return (
typeof entry.name === "string" &&
typeof entry.version === "string" &&
typeof entry.license === "string" &&
(entry.ecosystem === "npm" || entry.ecosystem === "go" || entry.ecosystem === "source") &&
(entry.repository === undefined || typeof entry.repository === "string") &&
(entry.license_text === undefined || typeof entry.license_text === "string") &&
(entry.stale === undefined || typeof entry.stale === "boolean")
);
}
function markStaleGoEntries(entries: LicenseEntry[]): LicenseEntry[] {
return entries.map((entry) => ({ ...entry, stale: true }));
}
function parseGoRows(raw: string): GoRow[] {
const rows: GoRow[] = [];
for (const line of raw.split(/\r?\n/)) {
if (!line.trim()) continue;
const [pkg, licensePath, licenseURL, licenseName] = line.split("|||");
if (!pkg) continue;
rows.push({
pkg: pkg.trim(),
licensePath: (licensePath ?? "").trim(),
licenseURL: (licenseURL ?? "").trim(),
licenseName: (licenseName ?? "").trim(),
});
}
return rows;
}
function isThirdPartyGoRow(row: GoRow): boolean {
return !row.pkg.startsWith("github.com/kandev/kandev");
}
// Extract module name + version from go-licenses' `LicenseURL` / `LicensePath`.
// We try a few common URL shapes (GitHub blob, cs.opensource.google, generic
// `.../@version/...` in the gopath cache) and fall back to the package path.
function moduleAndVersion(row: GoRow): { module: string; version: string } {
// GitHub-style: https://<host>/<owner>/<repo>/blob/<version>/<path>
const github = row.licenseURL.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/]+)\/blob\/([^/]+)\//);
if (github) {
const [, host, owner, repo, version] = github;
return { module: `${host}/${owner}/${repo}`, version };
}
// cs.opensource.google-style: https://cs.opensource.google/go/x/<repo>/+/<version>:LICENSE
const csgoogle = row.licenseURL.match(/cs\.opensource\.google\/(.+?)\/\+\/([^:/]+)/);
if (csgoogle) {
return { module: `golang.org/${csgoogle[1]}`, version: csgoogle[2] };
}
// Fall back to the local gopath cache path: .../<module>@<version>/LICENSE
const cachePath = row.licensePath.match(/[\\/]([^\\/]+)@([^\\/]+)[\\/]/);
if (cachePath) {
// The first capture is the leaf dir; reconstruct the module from row.pkg
// because the leaf alone loses the host/owner prefix.
return { module: stripVersionSuffix(row.pkg), version: cachePath[2] };
}
return { module: row.pkg, version: "" };
}
// Some modules embed a major version in their import path (`/v2`, `/v3`, etc).
// Keep that suffix — it's part of the canonical module name in Go.
function stripVersionSuffix(pkg: string): string {
return pkg;
}
function repoFromGoURL(licenseURL: string): string | undefined {
const github = licenseURL.match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/blob\//);
if (github) return github[1];
const csgoogle = licenseURL.match(/^(https?:\/\/cs\.opensource\.google\/[^/]+\/[^/]+)\//);
if (csgoogle) return csgoogle[1];
return undefined;
}
function toGoEntry(row: GoRow): LicenseEntry {
const { module, version } = moduleAndVersion(row);
const licenseText = row.licensePath ? readSafe(row.licensePath) : undefined;
const repo = repoFromGoURL(row.licenseURL);
return {
name: module,
version: version || "unknown",
license: normalizeLicense(row.licenseName),
...(repo ? { repository: repo } : {}),
...(licenseText ? { license_text: licenseText } : {}),
ecosystem: "go",
};
}
function dedupe(entries: LicenseEntry[]): LicenseEntry[] {
const seen = new Map<string, LicenseEntry>();
for (const e of entries) {
const key = `${e.ecosystem}|${e.name}|${e.version}`;
const existing = seen.get(key);
if (!existing) {
seen.set(key, e);
continue;
}
// Prefer the entry with the richer payload (license_text, repository).
seen.set(key, mergeEntries(existing, e));
}
return [...seen.values()];
}
function mergeEntries(a: LicenseEntry, b: LicenseEntry): LicenseEntry {
return {
name: a.name,
version: a.version,
license: a.license !== UNKNOWN_LICENSE ? a.license : b.license,
repository: a.repository ?? b.repository,
license_text: a.license_text ?? b.license_text,
...(a.stale || b.stale ? { stale: true } : {}),
ecosystem: a.ecosystem,
};
}
function sortEntries(entries: LicenseEntry[]): LicenseEntry[] {
return [...entries].sort((a, b) => {
if (a.ecosystem !== b.ecosystem) return a.ecosystem < b.ecosystem ? -1 : 1;
if (a.name !== b.name) return a.name < b.name ? -1 : 1;
if (a.version !== b.version) return a.version < b.version ? -1 : 1;
return 0;
});
}
function main(): void {
mkdirSync(OUTPUT_DIR, { recursive: true });
process.stderr.write("[licenses] collecting npm entries via pnpm…\n");
const npm = collectNpmEntries();
process.stderr.write(`[licenses] npm entries: ${npm.length}\n`);
let goEntries: LicenseEntry[] = [];
try {
process.stderr.write("[licenses] collecting go entries via go-licenses…\n");
goEntries = collectGoEntries();
process.stderr.write(`[licenses] go entries: ${goEntries.length}\n`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
process.stderr.write(`[licenses] go ingestion failed: ${message}\n`);
process.exit(1);
}
const merged = sortEntries(dedupe([...npm, ...goEntries, ...THIRD_PARTY_SOURCE_NOTICES]));
const json = JSON.stringify(merged, null, 2) + "\n";
writeFileSync(OUTPUT_FILE, json);
process.stderr.write(`[licenses] wrote ${merged.length} entries -> ${OUTPUT_FILE}\n`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
}