mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-03 14:01:38 +00:00
267 lines
9.1 KiB
JavaScript
Executable File
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();
|
|
}
|