#!/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 } from "node:url"; type Ecosystem = "npm" | "go"; interface LicenseEntry { name: string; version: string; license: string; repository?: string; license_text?: string; 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"; 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; 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`); return []; } finally { rmSync(tmplPath, { force: true }); } const rows = parseGoRows(raw); return rows.filter(isThirdPartyGoRow).map(toGoEntry); } 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://///blob// 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//+/: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: .../@/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(); 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, 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])); const json = JSON.stringify(merged, null, 2) + "\n"; writeFileSync(OUTPUT_FILE, json); process.stderr.write(`[licenses] wrote ${merged.length} entries -> ${OUTPUT_FILE}\n`); } main();