#!/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"; 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"; 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`); 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>; return ( typeof entry.name === "string" && typeof entry.version === "string" && typeof entry.license === "string" && (entry.ecosystem === "npm" || entry.ecosystem === "go") && (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://///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, ...(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])); 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(); }