#!/usr/bin/env node // Generates a lightweight SBOM-style dependency manifest by shelling out to // `pnpm list -r --json --depth=Infinity` and writing the result to // `sbom-pnpm.json` in the repo root. // // Why pnpm-list instead of @cyclonedx/cyclonedx-npm: // - cyclonedx-npm is opinionated about lockfile shape and tends to mis-handle // pnpm workspaces (apps/*, packages/*). pnpm's own resolver is the source of // truth, so we capture its output verbatim. // - The artifact is consumed for audit traceability (which version of which // package shipped in this image), not for CycloneDX validator gating. JSON // tree is sufficient. // // The script is invoked from the Docker `build` stage and the resulting JSON // is COPYed into each runtime stage so that the SBOM ships alongside the app. import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(HERE, ".."); const OUTPUT_PATH = process.env.SBOM_OUTPUT_PATH ? path.resolve(process.env.SBOM_OUTPUT_PATH) : path.join(ROOT, "sbom-pnpm.json"); function runPnpmList() { const result = spawnSync( "pnpm", ["list", "-r", "--json", "--depth=Infinity"], { cwd: ROOT, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }, ); if (result.error) { throw result.error; } if (result.status !== 0) { const stderr = result.stderr?.toString() ?? ""; throw new Error(`pnpm list exited with status ${result.status}: ${stderr}`); } return result.stdout?.toString() ?? ""; } function parseTree(raw) { if (!raw.trim()) { return []; } try { return JSON.parse(raw); } catch (err) { throw new Error(`pnpm list output was not valid JSON: ${(err && err.message) || err}`); } } function repoVersion() { try { const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")); return { name: pkg.name ?? null, version: pkg.version ?? null }; } catch { return { name: null, version: null }; } } function main() { const raw = runPnpmList(); const tree = parseTree(raw); const repo = repoVersion(); const document = { schema: "toolctrl.sbom-pnpm/v1", generatedAt: new Date().toISOString(), generator: "scripts/generate-sbom.mjs", source: "pnpm list -r --json --depth=Infinity", repo, workspaces: tree, }; fs.mkdirSync(path.dirname(OUTPUT_PATH), { recursive: true }); fs.writeFileSync(OUTPUT_PATH, JSON.stringify(document, null, 2)); // eslint-disable-next-line no-console console.log(`SBOM written: ${OUTPUT_PATH} (${tree.length} workspace entries)`); } main();