#!/usr/bin/env node // Materializes generated public docs only while npm assembles a package tarball. import { execFileSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { renderDocsHeadingMap } from "./docs-list.js"; const DOCS_MAP_PATH = path.join("docs", "docs_map.md"); const MATURITY_DOCS_PATHS = [ path.join("docs", "maturity", "scorecard.md"), path.join("docs", "maturity", "taxonomy.md"), ]; const MATURITY_STATE_PATH = path.join("qa", "maturity-docs-state.json"); const RECEIPT_PATH = path.join(".artifacts", "package-docs-map", "receipt.json"); const SHA256_RE = /^[0-9a-f]{64}$/u; function activePreparationError() { return Object.assign( new Error( `Another package preparation owns ${DOCS_MAP_PATH}; wait for it to finish or run \`node scripts/openclaw-postpack.mjs\` after an interrupted pack.`, ), { code: "PACKAGE_DOCS_MAP_ACTIVE" }, ); } function preparationRestoreError(error, restoreError) { return new AggregateError( [error, restoreError], "Writing generated package docs failed and their source state could not be restored.", { cause: error }, ); } function sha256(content) { return createHash("sha256").update(content).digest("hex"); } async function readSourceContent(cwd, relativePath) { const filePath = path.join(cwd, relativePath); return existsSync(filePath) ? await readFile(filePath, "utf8") : null; } function sourceArtifact(relativePath, original, generatedSha256 = null) { return { path: relativePath, original, originalSha256: original === null ? null : sha256(original), generatedSha256, }; } function receiptArtifacts(receipt) { const legacyOriginal = receipt?.original; const artifacts = Array.isArray(receipt?.artifacts) ? receipt.artifacts : legacyOriginal === null || typeof legacyOriginal === "string" ? [sourceArtifact(DOCS_MAP_PATH, legacyOriginal, receipt?.generatedSha256)] : []; const allowed = new Set([DOCS_MAP_PATH, ...MATURITY_DOCS_PATHS]); const seen = new Set(); for (const artifact of artifacts) { if ( !artifact || !allowed.has(artifact.path) || seen.has(artifact.path) || (artifact.original !== null && typeof artifact.original !== "string") || artifact.originalSha256 !== (artifact.original === null ? null : sha256(artifact.original)) || (artifact.generatedSha256 === null ? artifact.path !== DOCS_MAP_PATH : typeof artifact.generatedSha256 !== "string" || !SHA256_RE.test(artifact.generatedSha256)) ) { throw new Error(`Invalid package docs-map receipt at ${RECEIPT_PATH}.`); } seen.add(artifact.path); } if (!seen.has(DOCS_MAP_PATH)) { throw new Error(`Invalid package docs-map receipt at ${RECEIPT_PATH}.`); } return artifacts; } // Hash-fenced receipts can recover interrupted work only when artifacts are // always either their complete original bytes or their complete generated bytes. async function writeFileAtomically(filePath, content) { const temporaryPath = `${filePath}.${randomUUID()}.tmp`; try { await writeFile(temporaryPath, content, { encoding: "utf8", flag: "wx" }); await rename(temporaryPath, filePath); } finally { await rm(temporaryPath, { force: true }); } } async function writeReceipt(receiptPath, artifacts, exclusive) { const content = `${JSON.stringify({ artifacts })}\n`; if (exclusive) { await writeFile(receiptPath, content, { encoding: "utf8", flag: "wx" }); return; } await writeFileAtomically(receiptPath, content); } async function renderPackageMaturityDocs(cwd, receiptDir) { if (!existsSync(path.join(cwd, MATURITY_STATE_PATH))) { return []; } await mkdir(receiptDir, { recursive: true }); const outputDir = await mkdtemp(path.join(receiptDir, "maturity-")); try { const resolveFromSource = createRequire(path.join(cwd, "package.json")); const tsxLoader = pathToFileURL(resolveFromSource.resolve("tsx")).href; execFileSync( process.execPath, [ "--import", tsxLoader, path.join(cwd, "scripts", "qa", "render-maturity-docs.ts"), "--output-dir", outputDir, "--docs-root", path.join(cwd, "docs"), "--taxonomy", path.join(cwd, "taxonomy.yaml"), "--scores", path.join(cwd, "qa", "maturity-scores.yaml"), "--state", path.join(cwd, MATURITY_STATE_PATH), "--strict-inputs", ], { cwd, stdio: ["ignore", 2, 2] }, ); return await Promise.all( MATURITY_DOCS_PATHS.map(async (relativePath) => { const generated = await readFile( path.join(outputDir, path.relative("docs", relativePath)), "utf8", ); return { artifact: sourceArtifact( relativePath, await readSourceContent(cwd, relativePath), sha256(generated), ), generated, }; }), ); } finally { await rm(outputDir, { force: true, recursive: true }); } } /** Restore every generated package doc only while its recorded content remains untouched. */ export async function restorePackageDocsMap(cwd = process.cwd()) { const receiptPath = path.join(cwd, RECEIPT_PATH); if (!existsSync(receiptPath)) { return false; } const receipt = JSON.parse(await readFile(receiptPath, "utf8")); const artifacts = receiptArtifacts(receipt); const current = await Promise.all( artifacts.map((artifact) => readSourceContent(cwd, artifact.path)), ); // Validate every owner before restoring any page; an operator edit must never // leave a partially restored package lifecycle or release another pack's lock. for (const [index, artifact] of artifacts.entries()) { if (current[index] === artifact.original) { continue; } if (current[index] === null || sha256(current[index]) !== artifact.generatedSha256) { throw new Error( `Refusing to restore ${artifact.path} because it changed after prepack generated it.`, ); } } for (const [index, artifact] of artifacts.entries()) { if (current[index] === artifact.original) { continue; } const filePath = path.join(cwd, artifact.path); if (artifact.original === null) { await rm(filePath); } else { await writeFileAtomically(filePath, artifact.original); } } await rm(receiptPath, { force: true }); return true; } /** Generate maturity pages before the docs map while one receipt owns every source mutation. */ export async function preparePackageDocsMap(cwd = process.cwd()) { const receiptPath = path.join(cwd, RECEIPT_PATH); if (existsSync(receiptPath)) { throw activePreparationError(); } const mapPath = path.join(cwd, DOCS_MAP_PATH); const docsDir = path.join(cwd, "docs"); const maturityDocs = await renderPackageMaturityDocs(cwd, path.dirname(receiptPath)); let content = maturityDocs.length === 0 ? renderDocsHeadingMap(docsDir) : null; const original = await readSourceContent(cwd, DOCS_MAP_PATH); if (maturityDocs.length === 0 && original === content) { return false; } const mapArtifact = sourceArtifact( DOCS_MAP_PATH, original, content === null ? null : sha256(content), ); const artifacts = [...maturityDocs.map((entry) => entry.artifact), mapArtifact]; await mkdir(path.dirname(receiptPath), { recursive: true }); try { await writeReceipt(receiptPath, artifacts, true); } catch (error) { if (error?.code === "EEXIST") { throw activePreparationError(); } throw error; } try { for (const { artifact, generated } of maturityDocs) { await mkdir(path.dirname(path.join(cwd, artifact.path)), { recursive: true }); await writeFileAtomically(path.join(cwd, artifact.path), generated); } if (maturityDocs.length > 0) { // Full page headings must exist before indexing or the shipped map loses navigation. content = renderDocsHeadingMap(docsDir); mapArtifact.generatedSha256 = sha256(content); await writeReceipt(receiptPath, artifacts, false); } await writeFileAtomically(mapPath, content); } catch (error) { try { await restorePackageDocsMap(cwd); } catch (restoreError) { throw preparationRestoreError(error, restoreError); } throw error; } return true; } async function main(argv = process.argv.slice(2)) { if (argv.length !== 1 || (argv[0] !== "prepare" && argv[0] !== "restore")) { console.error("Usage: node scripts/package-docs-map.mjs "); process.exitCode = 1; return; } const changed = argv[0] === "prepare" ? await preparePackageDocsMap() : await restorePackageDocsMap(); console.error( changed ? `package-docs-map: ${argv[0] === "prepare" ? "generated" : "removed"} transient docs map.` : `package-docs-map: no ${argv[0] === "prepare" ? "generation" : "cleanup"} needed.`, ); } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { await main(); }