Files
openclaw/scripts/package-docs-map.mjs
2026-08-01 09:01:10 -07:00

267 lines
9.1 KiB
JavaScript
Executable File

#!/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 <prepare|restore>");
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();
}