docs: generate docs map at publish time (#117398)

* docs: generate docs map at publish time

* fix: canonicalize Docker pack entry path

* fix: preserve package lifecycle ownership
This commit is contained in:
Peter Steinberger
2026-08-01 07:01:43 -07:00
committed by GitHub
parent e4d1b7e0d3
commit 385f1dee16
27 changed files with 962 additions and 11411 deletions

View File

@@ -6,6 +6,7 @@ on:
- main
paths:
- docs/**
- scripts/docs-list.js
- scripts/docs-sync-publish.mjs
- .github/workflows/docs-sync-publish.yml
workflow_dispatch:

1
.gitignore vendored
View File

@@ -50,7 +50,6 @@ apps/swabble/Package.resolved
**/ModuleCache/
bin/
bin/clawdbot-mac
bin/docs-list
apps/macos/.build-local/
apps/macos/.swiftpm/
apps/shared/MoltbotKit/.swiftpm/

View File

@@ -15,7 +15,8 @@ This directory owns docs authoring, Mintlify link rules, and docs i18n policy.
- For docs, UI copy, and picker lists, order services/providers alphabetically unless the section is explicitly describing runtime order or auto-detection order.
- Keep bundled plugin naming consistent with the repo-wide plugin terminology rules in the root `AGENTS.md`.
- Generated docs, never hand-edit: `docs/plugins/reference/**`, `docs/plugins/reference.md`, and `docs/plugins/plugin-inventory.md` come from `pnpm plugins:inventory:gen`; `docs/docs_map.md` from `pnpm docs:map:gen`; `docs/maturity/**` from `pnpm maturity:render`.
- Generated docs, never hand-edit: `docs/plugins/reference/**`, `docs/plugins/reference.md`, and `docs/plugins/plugin-inventory.md` come from `pnpm plugins:inventory:gen`; `docs/maturity/**` from `pnpm maturity:render`.
- The public and packaged docs map is generated from `pnpm docs:list --headings` during publishing and packaging. Keep only the small source stub at `docs/docs_map.md`; never commit the expanded heading mirror.
## Internal Docs

File diff suppressed because it is too large Load Diff

View File

@@ -300,8 +300,8 @@ restart` from the real environment and verify the plist. Product follow-up:
`CI release gate <sha>`, which `scripts/verify-pr-hosted-gates.mjs`
accepts. Then `scripts/pr` prepare/merge as usual.
- **Gates that CI enforces beyond focused tests**: docs map
(`pnpm docs:map:gen` after adding any docs page), oxlint (`no-map-spread`,
- **Gates that CI enforces beyond focused tests**: docs consistency
(`pnpm check:docs` after changing docs), oxlint (`no-map-spread`,
`max-lines` — split files, never suppress), `check:test-types`, knip
deadcode (export only what prod consumes; route tests through public APIs),
and the live-test shard classifier

View File

@@ -1473,7 +1473,7 @@
"check:database-first-legacy-stores": "node scripts/check-database-first-legacy-stores.mjs",
"check:deprecated-api-usage": "node scripts/check-deprecated-api-usage.mjs",
"check:deprecated-jsdoc": "node scripts/check-deprecated-jsdoc.mjs",
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-mdx && pnpm docs:check-i18n-glossary && pnpm docs:check-links && pnpm docs:map:check",
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-mdx && pnpm docs:check-i18n-glossary && pnpm docs:check-links",
"check:env-var-count": "node scripts/check-env-var-count.mjs",
"check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check",
"check:import-cycles": "node --import tsx scripts/check-import-cycles.ts",
@@ -1532,15 +1532,13 @@
"db:kysely:gen": "node scripts/generate-kysely-types.mjs",
"dev": "node scripts/run-node.mjs",
"dev:ui:mock": "node --import tsx scripts/control-ui-mock-dev.ts",
"docs:bin": "node scripts/build-docs-list.mjs",
"docs:check-i18n-glossary": "node scripts/check-docs-i18n-glossary.mjs",
"docs:check-links": "node scripts/docs-link-audit.mjs",
"docs:check-links:anchors": "node scripts/docs-link-audit.mjs --anchors",
"docs:check-mdx": "node scripts/check-docs-mdx.mjs docs README.md",
"docs:dev": "cd docs && mint dev",
"docs:list": "node scripts/docs-list.js",
"docs:map:gen": "node scripts/generate-docs-map.mjs",
"docs:map:check": "node scripts/generate-docs-map.mjs --check",
"docs:map:gen": "node scripts/docs-list.js --headings",
"docs:spellcheck": "bash scripts/docs-spellcheck.sh",
"docs:spellcheck:fix": "bash scripts/docs-spellcheck.sh --write",
"maturity:check": "node --import tsx scripts/qa/render-maturity-docs.ts --check",
@@ -1659,7 +1657,7 @@
"plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts",
"plugins:sync:check": "node --import tsx scripts/sync-plugin-versions.ts --check",
"postinstall": "node scripts/postinstall-bundled-plugins.mjs",
"postpack": "node scripts/package-changelog.mjs restore",
"postpack": "node scripts/openclaw-postpack.mjs",
"preinstall": "node scripts/preinstall-package-manager-warning.mjs",
"prepack": "node --import tsx scripts/openclaw-prepack.ts",
"prepare": "node scripts/prepare-git-hooks.mjs",

View File

@@ -1,15 +0,0 @@
#!/usr/bin/env node
// Writes the `bin/docs-list` wrapper used by package scripts and docs tooling.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const binDir = path.join(root, "bin");
const binPath = path.join(binDir, "docs-list");
fs.mkdirSync(binDir, { recursive: true });
const wrapper = `#!/usr/bin/env node\nimport { spawnSync } from "node:child_process";\nimport path from "node:path";\nimport { fileURLToPath } from "node:url";\n\nconst here = path.dirname(fileURLToPath(import.meta.url));\nconst script = path.join(here, "..", "scripts", "docs-list.js");\n\nconst result = spawnSync(process.execPath, [script], { stdio: "inherit" });\nprocess.exit(result.status ?? 1);\n`;
fs.writeFileSync(binPath, wrapper, { mode: 0o755 });

4
scripts/docs-list.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
export function renderDocsHeadingMap(
docsDir?: string,
options?: { relativePath?: (base: string, fullPath: string) => string },
): string;

View File

@@ -1,27 +1,31 @@
#!/usr/bin/env node
// Lists source docs pages and routes for docs-aware tooling.
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
// Lists source docs pages and renders on-demand heading metadata for docs-aware tooling.
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
import { join, relative } from "node:path";
process.stdout.on("error", (error) => {
if (error?.code === "EPIPE") {
process.exit(0);
}
throw error;
});
import { fileURLToPath } from "node:url";
const DOCS_DIR = join(process.cwd(), "docs");
if (!existsSync(DOCS_DIR)) {
console.error("docs:list: missing docs directory. Run from repo root.");
process.exit(1);
}
if (!statSync(DOCS_DIR).isDirectory()) {
console.error("docs:list: docs path is not a directory.");
process.exit(1);
}
const DOCS_LIST_EXCLUDED_DIRS = new Set(["archive", "research"]);
const DOCS_MAP_EXCLUDED_DIRS = new Set([
".generated",
"archive",
"assets",
"images",
"internal",
"research",
"snippets",
]);
const DOCS_MAP_EXCLUDED_FILES = new Set(["AGENTS.md", "CLAUDE.md", "docs_map.md"]);
const EXCLUDED_DIRS = new Set(["archive", "research"]);
function assertDocsDir(docsDir) {
if (!existsSync(docsDir)) {
throw new Error("docs:list: missing docs directory. Run from repo root.");
}
if (!statSync(docsDir).isDirectory()) {
throw new Error("docs:list: docs path is not a directory.");
}
}
/**
* @param {unknown[]} values
@@ -50,9 +54,14 @@ function compactStrings(values) {
/**
* @param {string} dir
* @param {string} base
* @param {{
* excludedDirs?: Set<string>;
* excludedFiles?: Set<string>;
* relativePath?: (base: string, fullPath: string) => string;
* }} options
* @returns {string[]}
*/
function walkMarkdownFiles(dir, base = dir) {
function walkMarkdownFiles(dir, base = dir, options = {}) {
const entries = readdirSync(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
@@ -61,12 +70,16 @@ function walkMarkdownFiles(dir, base = dir) {
}
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
if (EXCLUDED_DIRS.has(entry.name)) {
if (options.excludedDirs?.has(entry.name)) {
continue;
}
files.push(...walkMarkdownFiles(fullPath, base));
} else if (entry.isFile() && /\.(md|mdx)$/i.test(entry.name)) {
files.push(relative(base, fullPath));
files.push(...walkMarkdownFiles(fullPath, base, options));
} else if (
entry.isFile() &&
/\.(md|mdx)$/iu.test(entry.name) &&
!options.excludedFiles?.has(entry.name)
) {
files.push((options.relativePath ?? relative)(base, fullPath));
}
}
return files.toSorted((a, b) => a.localeCompare(b));
@@ -156,24 +169,182 @@ function extractMetadata(fullPath) {
return { summary: normalized, readWhen };
}
console.log("Listing all markdown files in docs folder:");
const markdownFiles = walkMarkdownFiles(DOCS_DIR);
for (const relativePath of markdownFiles) {
const fullPath = join(DOCS_DIR, relativePath);
const { summary, readWhen, error } = extractMetadata(fullPath);
if (summary) {
console.log(`${relativePath} - ${summary}`);
if (readWhen.length > 0) {
console.log(` Read when: ${readWhen.join("; ")}`);
}
} else {
const reason = error ? ` - [${error}]` : "";
console.log(`${relativePath}${reason}`);
function stripFrontmatter(raw) {
if (!raw.startsWith("---\n") && !raw.startsWith("---\r\n")) {
return raw;
}
const lines = raw.split(/\r?\n/u);
for (let index = 1; index < lines.length; index += 1) {
if (lines[index] === "---" || lines[index] === "...") {
return lines.slice(index + 1).join("\n");
}
}
return raw;
}
console.log(
'\nReminder: keep docs up to date as behavior changes. When your task matches any "Read when" hint above (React hooks, cache directives, database work, tests, etc.), read that doc before coding, and suggest new coverage when it is missing.',
);
function escapeMarkdownHtmlText(value) {
return value.replace(/&/gu, "&amp;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;");
}
function cleanHeadingText(value) {
const codeSpans = [];
const withCodePlaceholders = value.replace(/(`+)([^`\n]*?)\1/gu, (_match, _ticks, content) => {
const index = codeSpans.push(content) - 1;
return `\u{e000}${index}\u{e001}`;
});
const normalized = withCodePlaceholders
.replace(/\s+#+\s*$/u, "")
.replace(/\[([^\]]+)\]\([^)]*\)/gu, "$1")
.replace(/[*_~`]/gu, "")
.replace(/\s+/gu, " ")
.trim()
.replace(/\u{e000}(\d+)\u{e001}/gu, (_match, index) => {
const content = codeSpans[Number(index)] ?? "";
return /[*_~[\]()!]/u.test(content) ? `\`${content}\`` : content;
});
// Docs map is Markdown consumed by humans and agents. Escape HTML instead of
// trying to strip tags so malformed source headings cannot reintroduce markup.
return escapeMarkdownHtmlText(normalized);
}
function extractHeadings(raw) {
const headings = [];
const lines = stripFrontmatter(raw).split(/\r?\n/u);
let fenceMarker = null;
for (const rawLine of lines) {
const trimmed = rawLine.trim();
const fenceMatch = /^(?<marker>`{3,}|~{3,})/u.exec(trimmed);
if (fenceMatch) {
const marker = fenceMatch.groups.marker[0];
fenceMarker = fenceMarker === marker ? null : (fenceMarker ?? marker);
continue;
}
if (fenceMarker) {
continue;
}
const match = /^(#{1,4})\s+(.+)$/u.exec(rawLine);
if (!match) {
continue;
}
const text = cleanHeadingText(match[2]);
if (text) {
headings.push({ depth: match[1].length, text });
}
}
return headings;
}
function routeForFile(relativePath) {
const withoutExtension = relativePath.replace(/\.mdx?$/iu, "");
if (withoutExtension === "index") {
return "/";
}
if (withoutExtension.endsWith("/index")) {
return `/${withoutExtension.slice(0, -"/index".length)}`;
}
return `/${withoutExtension}`;
}
function normalizeDocsMapRelativePath(relativePath) {
return relativePath.replace(/\\/gu, "/");
}
/** Render the publish-only docs heading map without creating a source-tree mirror. */
export function renderDocsHeadingMap(docsDir = DOCS_DIR, options = {}) {
assertDocsDir(docsDir);
const files = walkMarkdownFiles(docsDir, docsDir, {
excludedDirs: DOCS_MAP_EXCLUDED_DIRS,
excludedFiles: DOCS_MAP_EXCLUDED_FILES,
relativePath: options.relativePath,
})
.map(normalizeDocsMapRelativePath)
.toSorted((left, right) => (left < right ? -1 : left > right ? 1 : 0));
const lines = [
"---",
'summary: "Generated heading map for OpenClaw docs pages"',
'read_when: "Finding which docs page covers a topic before reading the page"',
'title: "Docs map"',
"---",
"",
"# OpenClaw docs map",
"",
"This file is generated from `docs/**/*.md` and `docs/**/*.mdx` headings to help agents navigate the documentation tree.",
"Do not edit it by hand; run `pnpm docs:map:gen`.",
"",
];
for (const relativePath of files) {
const headings = extractHeadings(readFileSync(join(docsDir, relativePath), "utf8"));
lines.push(`## ${relativePath}`);
lines.push("");
lines.push(`- Route: ${routeForFile(relativePath)}`);
if (headings.length === 0) {
lines.push("- Headings: none");
} else {
lines.push("- Headings:");
for (const heading of headings) {
lines.push(` - H${heading.depth}: ${heading.text}`);
}
}
lines.push("");
}
return `${lines.join("\n").trimEnd()}\n`;
}
function runDocsList(docsDir = DOCS_DIR) {
assertDocsDir(docsDir);
console.log("Listing all markdown files in docs folder:");
const markdownFiles = walkMarkdownFiles(docsDir, docsDir, {
excludedDirs: DOCS_LIST_EXCLUDED_DIRS,
});
for (const relativePath of markdownFiles) {
const fullPath = join(docsDir, relativePath);
const { summary, readWhen, error } = extractMetadata(fullPath);
if (summary) {
console.log(`${relativePath} - ${summary}`);
if (readWhen.length > 0) {
console.log(` Read when: ${readWhen.join("; ")}`);
}
} else {
const reason = error ? ` - [${error}]` : "";
console.log(`${relativePath}${reason}`);
}
}
console.log(
'\nReminder: keep docs up to date as behavior changes. When your task matches any "Read when" hint above (React hooks, cache directives, database work, tests, etc.), read that doc before coding, and suggest new coverage when it is missing.',
);
}
function main(argv = process.argv.slice(2)) {
process.stdout.on("error", (error) => {
if (error?.code === "EPIPE") {
process.exit(0);
}
throw error;
});
if (argv.length === 0) {
runDocsList();
return;
}
if (argv.length === 1 && argv[0] === "--headings") {
process.stdout.write(renderDocsHeadingMap());
return;
}
console.error("Usage: pnpm docs:list [--headings]");
process.exitCode = 1;
}
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}

View File

@@ -20,6 +20,8 @@ export function applyLocaleNavLabelOverlay(
): Record<string, unknown>;
/** Composes the publish docs configuration with generated locale navigation. */
export function composeDocsConfig(): Record<string, unknown>;
/** Writes the public heading map into the publish tree without committing an expanded mirror. */
export function writePublishedDocsMap(targetDocsDir: string): string;
/**
* Mirrors ClawHub docs into the target docs tree.
*/

View File

@@ -5,6 +5,7 @@ import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { renderDocsHeadingMap } from "./docs-list.js";
import { repairMintlifyAccordionIndentation } from "./lib/mintlify-accordion.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -784,6 +785,7 @@ function syncDocsTree(targetRoot, options = {}) {
`${targetDocsDir}/`,
]);
pruneInternalDocs(targetDocsDir);
writePublishedDocsMap(targetDocsDir);
for (const locale of GENERATED_LOCALES) {
const sourceTmPath = path.join(SOURCE_DOCS_DIR, ".i18n", locale.tmFile);
@@ -805,6 +807,13 @@ function syncDocsTree(targetRoot, options = {}) {
return { clawhub: clawhubSource };
}
/** Writes the public heading map into the publish tree without committing an expanded mirror. */
export function writePublishedDocsMap(targetDocsDir) {
const outputPath = path.join(targetDocsDir, "docs_map.md");
fs.writeFileSync(outputPath, renderDocsHeadingMap(SOURCE_DOCS_DIR), "utf8");
return outputPath;
}
function writeSyncMetadata(targetRoot, args, sources) {
const metadata = {
repository: args.sourceRepo || "",

View File

@@ -1,5 +0,0 @@
#!/usr/bin/env node
export namespace testing {
export { cleanHeadingText };
}
declare function cleanHeadingText(value: unknown): unknown;

View File

@@ -1,219 +0,0 @@
#!/usr/bin/env node
// Generates docs/docs_map.md from source docs headings for LLM navigation.
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join, relative } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = process.cwd();
const DOCS_DIR = join(ROOT, "docs");
const OUTPUT_PATH = join(DOCS_DIR, "docs_map.md");
const MARKDOWN_EXTENSIONS = /\.mdx?$/iu;
const EXCLUDED_DIRS = new Set([
".generated",
"archive",
"assets",
"images",
"internal",
"research",
"snippets",
]);
const EXCLUDED_FILES = new Set(["AGENTS.md", "CLAUDE.md", "docs_map.md"]);
if (!existsSync(DOCS_DIR)) {
console.error("docs:map: missing docs directory. Run from repo root.");
process.exit(1);
}
if (!statSync(DOCS_DIR).isDirectory()) {
console.error("docs:map: docs path is not a directory.");
process.exit(1);
}
function normalizeSlashes(value) {
return value.replace(/\\/gu, "/");
}
function isMarkdownFile(name) {
return MARKDOWN_EXTENSIONS.test(name);
}
function shouldSkipFile(relativePath) {
const parts = normalizeSlashes(relativePath).split("/");
if (parts.some((part) => part.startsWith("."))) {
return true;
}
if (parts.some((part) => EXCLUDED_DIRS.has(part))) {
return true;
}
return EXCLUDED_FILES.has(parts.at(-1));
}
function walkMarkdownFiles(dir, base = dir) {
const files = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith(".")) {
continue;
}
const fullPath = join(dir, entry.name);
const relativePath = relative(base, fullPath);
if (entry.isDirectory()) {
if (EXCLUDED_DIRS.has(entry.name)) {
continue;
}
files.push(...walkMarkdownFiles(fullPath, base));
continue;
}
if (!entry.isFile() || !isMarkdownFile(entry.name) || shouldSkipFile(relativePath)) {
continue;
}
files.push(normalizeSlashes(relativePath));
}
return files.toSorted((left, right) => (left < right ? -1 : left > right ? 1 : 0));
}
function stripFrontmatter(raw) {
if (!raw.startsWith("---\n") && !raw.startsWith("---\r\n")) {
return raw;
}
const lines = raw.split(/\r?\n/u);
for (let index = 1; index < lines.length; index += 1) {
if (lines[index] === "---" || lines[index] === "...") {
return lines.slice(index + 1).join("\n");
}
}
return raw;
}
function escapeMarkdownHtmlText(value) {
return value.replace(/&/gu, "&amp;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;");
}
function cleanHeadingText(value) {
const codeSpans = [];
const withCodePlaceholders = value.replace(/(`+)([^`\n]*?)\1/gu, (_match, _ticks, content) => {
const index = codeSpans.push(content) - 1;
return `\u{e000}${index}\u{e001}`;
});
const normalized = withCodePlaceholders
.replace(/\s+#+\s*$/u, "")
.replace(/\[([^\]]+)\]\([^)]*\)/gu, "$1")
.replace(/[*_~`]/gu, "")
.replace(/\s+/gu, " ")
.trim()
.replace(/\u{e000}(\d+)\u{e001}/gu, (_match, index) => {
const content = codeSpans[Number(index)] ?? "";
return /[*_~[\]()!]/u.test(content) ? `\`${content}\`` : content;
});
// Docs map is Markdown consumed by humans and agents. Escape HTML instead of
// trying to strip tags so malformed source headings cannot reintroduce markup.
return escapeMarkdownHtmlText(normalized);
}
function extractHeadings(raw) {
const headings = [];
const lines = stripFrontmatter(raw).split(/\r?\n/u);
let fenceMarker = null;
for (const rawLine of lines) {
const trimmed = rawLine.trim();
const fenceMatch = /^(?<marker>`{3,}|~{3,})/u.exec(trimmed);
if (fenceMatch) {
const marker = fenceMatch.groups.marker[0];
fenceMarker = fenceMarker === marker ? null : (fenceMarker ?? marker);
continue;
}
if (fenceMarker) {
continue;
}
const match = /^(#{1,4})\s+(.+)$/u.exec(rawLine);
if (!match) {
continue;
}
const text = cleanHeadingText(match[2]);
if (text) {
headings.push({ depth: match[1].length, text });
}
}
return headings;
}
function routeForFile(relativePath) {
const withoutExtension = relativePath.replace(/\.mdx?$/iu, "");
if (withoutExtension === "index") {
return "/";
}
if (withoutExtension.endsWith("/index")) {
return `/${withoutExtension.slice(0, -"/index".length)}`;
}
return `/${withoutExtension}`;
}
function renderDocsMap() {
const files = walkMarkdownFiles(DOCS_DIR);
const lines = [
"---",
'summary: "Generated heading map for OpenClaw docs pages"',
'read_when: "Finding which docs page covers a topic before reading the page"',
'title: "Docs map"',
"---",
"",
"# OpenClaw docs map",
"",
"This file is generated from `docs/**/*.md` and `docs/**/*.mdx` headings to help agents navigate the documentation tree.",
"Do not edit it by hand; run `pnpm docs:map:gen`.",
"",
];
for (const relativePath of files) {
const fullPath = join(DOCS_DIR, relativePath);
const headings = extractHeadings(readFileSync(fullPath, "utf8"));
lines.push(`## ${relativePath}`);
lines.push("");
lines.push(`- Route: ${routeForFile(relativePath)}`);
if (headings.length === 0) {
lines.push("- Headings: none");
} else {
lines.push("- Headings:");
for (const heading of headings) {
lines.push(` - H${heading.depth}: ${heading.text}`);
}
}
lines.push("");
}
return `${lines.join("\n").trimEnd()}\n`;
}
function main() {
const check = process.argv.includes("--check");
const content = renderDocsMap();
if (check) {
if (!existsSync(OUTPUT_PATH)) {
console.error("docs:map: docs/docs_map.md is missing. Run `pnpm docs:map:gen`.");
process.exit(1);
}
const current = readFileSync(OUTPUT_PATH, "utf8");
if (current !== content) {
console.error("docs:map: docs/docs_map.md is out of date. Run `pnpm docs:map:gen`.");
process.exit(1);
}
console.log("docs:map: docs/docs_map.md is up to date.");
return;
}
writeFileSync(OUTPUT_PATH, content, "utf8");
console.log(`docs:map: wrote ${normalizeSlashes(relative(ROOT, OUTPUT_PATH))}.`);
}
const isMain = process.argv[1] ? fileURLToPath(import.meta.url) === process.argv[1] : false;
if (isMain) {
main();
}
export const testing = {
cleanHeadingText,
};

View File

@@ -28,6 +28,12 @@ export function resolveRuntimePackEnvironment(
now?: () => Date,
readGitCommit?: () => string | null,
): NodeJS.ProcessEnv & { OPENCLAW_BUILD_TIMESTAMP: string; GIT_COMMIT?: string };
export function runPreparedRuntimePack<T>(
prepare: () => void,
pack: () => T,
restore: () => void,
): T;
export function restoreRuntimePack(env: NodeJS.ProcessEnv, cwd?: string): void;
export function rewriteWorkspaceDependencyVersions(
packageJson: unknown,
workspacePackages: unknown,

View File

@@ -177,17 +177,34 @@ function prepareRuntimePack(profile, env) {
});
}
function restoreRuntimePack(env) {
export function restoreRuntimePack(env, cwd = process.cwd()) {
const script = `
const mod = await import("./scripts/package-changelog.mjs");
await mod.restorePackageChangelog();
const { existsSync } = await import("node:fs");
if (existsSync("./scripts/openclaw-postpack.mjs")) {
const mod = await import("./scripts/openclaw-postpack.mjs");
await mod.restorePrepackArtifacts();
} else {
// Historical source refs predate the composite lifecycle and only mutate CHANGELOG.md.
const mod = await import("./scripts/package-changelog.mjs");
await mod.restorePackageChangelog();
}
`;
runChecked(process.execPath, ["--input-type=module", "--eval", script], {
cwd,
env,
stdio: "inherit",
});
}
export function runPreparedRuntimePack(prepare, pack, restore) {
prepare();
try {
return pack();
} finally {
restore();
}
}
function packWorkspaceDependencies(npm, workspaceDirs, outputDir) {
return workspaceDirs.map((packageDir) => {
const packageJson = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8"));
@@ -277,16 +294,17 @@ function main() {
if (runtimePackPlan && runtimePackEnv && supportsPreparedRuntimePack(runtimePackEnv)) {
// This adapter-only archive is installed into OCM and never published.
// Standard npm pack still runs the full package build.
try {
prepareRuntimePack(runtimePackPlan.profile, runtimePackEnv);
const result = runNpm(npm, runtimePackPlan.packArgs, {
env: runtimePackEnv,
stdio: "inherit",
});
return result.status ?? 1;
} finally {
restoreRuntimePack(runtimePackEnv);
}
return runPreparedRuntimePack(
() => prepareRuntimePack(runtimePackPlan.profile, runtimePackEnv),
() => {
const result = runNpm(npm, runtimePackPlan.packArgs, {
env: runtimePackEnv,
stdio: "inherit",
});
return result.status ?? 1;
},
() => restoreRuntimePack(runtimePackEnv),
);
}
const plan = resolveWorkspaceInstallPlan(args, workspaceDirs);
if (!plan) {

View File

@@ -0,0 +1,2 @@
#!/usr/bin/env node
export function restorePrepackArtifacts(cwd?: string): Promise<void>;

22
scripts/openclaw-postpack.mjs Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env node
import path from "node:path";
import { fileURLToPath } from "node:url";
// Restores every source artifact temporarily rewritten for npm packaging.
import { restorePackageChangelog } from "./package-changelog.mjs";
import { restorePackageDocsMap } from "./package-docs-map.mjs";
export async function restorePrepackArtifacts(cwd = process.cwd()) {
await restorePackageChangelog(cwd);
// Release the lifecycle receipt only after every other source mutation settles.
await restorePackageDocsMap(cwd);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
await restorePrepackArtifacts();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}

View File

@@ -8,7 +8,9 @@ import { pathToFileURL } from "node:url";
import { formatErrorMessage } from "../src/infra/errors.ts";
import { readPositiveEnvInt } from "./lib/numeric-options.mjs";
import { writePackageDistInventoryForPublish } from "./lib/package-dist-inventory.ts";
import { restorePrepackArtifacts } from "./openclaw-postpack.mjs";
import { preparePackageChangelog } from "./package-changelog.mjs";
import { preparePackageDocsMap } from "./package-docs-map.mjs";
import { createPnpmRunnerSpawnSpec } from "./pnpm-runner.mjs";
const FULL_GIT_COMMIT_RE = /^[0-9a-f]{40}$/iu;
const requiredPreparedPathGroups = [
@@ -285,9 +287,29 @@ export async function preparePrepackArtifacts(env: NodeJS.ProcessEnv = process.e
ensurePreparedArtifacts();
await writeDistInventory();
runBuildSmoke();
await preparePackageChangelog(process.cwd(), {
allowUnreleased: resolvePrepackAllowUnreleasedChangelog(env),
});
// The docs-map receipt serializes source-mutating pack lifecycles before the
// changelog is touched, so concurrent packs cannot restore each other's files.
await preparePackageDocsMap(process.cwd());
try {
await preparePackageChangelog(process.cwd(), {
allowUnreleased: resolvePrepackAllowUnreleasedChangelog(env),
});
} catch (error) {
try {
await restorePrepackArtifacts(process.cwd());
} catch (restoreError) {
throw prepackPreparationRestoreError(error, restoreError);
}
throw error;
}
}
function prepackPreparationRestoreError(error: unknown, restoreError: unknown): AggregateError {
return new AggregateError(
[error, restoreError],
"Prepack preparation failed and source artifacts could not be restored.",
{ cause: error },
);
}
async function main(): Promise<void> {

View File

@@ -0,0 +1,2 @@
export function restorePackageDocsMap(cwd?: string): Promise<boolean>;
export function preparePackageDocsMap(cwd?: string): Promise<boolean>;

127
scripts/package-docs-map.mjs Executable file
View File

@@ -0,0 +1,127 @@
#!/usr/bin/env node
// Materializes the generated docs map only while npm assembles a package tarball.
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { renderDocsHeadingMap } from "./docs-list.js";
const DOCS_MAP_PATH = path.join("docs", "docs_map.md");
const RECEIPT_PATH = path.join(".artifacts", "package-docs-map", "receipt.json");
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 ${DOCS_MAP_PATH} failed and its source state could not be restored.`,
{ cause: error },
);
}
function sha256(content) {
return createHash("sha256").update(content).digest("hex");
}
/** Restore the source map stub after prepack replaced it with generated content. */
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"));
if (
!receipt ||
typeof receipt.generatedSha256 !== "string" ||
!/^[0-9a-f]{64}$/u.test(receipt.generatedSha256) ||
(receipt.original !== null && typeof receipt.original !== "string")
) {
throw new Error(`Invalid package docs-map receipt at ${RECEIPT_PATH}.`);
}
const mapPath = path.join(cwd, DOCS_MAP_PATH);
const current = existsSync(mapPath) ? await readFile(mapPath, "utf8") : null;
if (current === receipt.original) {
await rm(receiptPath, { force: true });
return true;
}
if (current === null || sha256(current) !== receipt.generatedSha256) {
throw new Error(
`Refusing to restore ${DOCS_MAP_PATH} because it changed after prepack generated it.`,
);
}
if (receipt.original === null) {
await rm(mapPath);
} else {
await writeFile(mapPath, receipt.original, "utf8");
}
await rm(receiptPath, { force: true });
return true;
}
/** Generate the shipped docs map for npm while recording the source state for cleanup. */
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 content = renderDocsHeadingMap(path.join(cwd, "docs"));
const original = existsSync(mapPath) ? await readFile(mapPath, "utf8") : null;
if (original === content) {
return false;
}
await mkdir(path.dirname(receiptPath), { recursive: true });
try {
await writeFile(
receiptPath,
`${JSON.stringify({ generatedSha256: sha256(content), original })}\n`,
{ encoding: "utf8", flag: "wx" },
);
} catch (error) {
if (error?.code === "EEXIST") {
throw activePreparationError();
}
throw error;
}
try {
await writeFile(mapPath, content, "utf8");
} 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();
}

View File

@@ -671,6 +671,40 @@ export async function prepareBundledAiRuntimePackage(
}
}
async function restorePackageSourceArtifacts(sourceDir, restoreDocsMap, restoreChangelog) {
await restoreChangelog(sourceDir);
// Release the lifecycle receipt only after every other source mutation settles.
await restoreDocsMap(sourceDir);
}
async function loadSourceDocsMapLifecycle(sourceDir) {
const modulePath = path.join(sourceDir, "scripts", "package-docs-map.mjs");
try {
await fs.access(modulePath);
} catch (error) {
if (error?.code === "ENOENT") {
return null;
}
throw error;
}
const lifecycle = await import(pathToFileURL(modulePath).href);
if (
typeof lifecycle.preparePackageDocsMap !== "function" ||
typeof lifecycle.restorePackageDocsMap !== "function"
) {
throw new Error(`source package docs-map lifecycle is invalid: ${modulePath}`);
}
return lifecycle;
}
function packagePreparationRestoreError(error, restoreError) {
return new AggregateError(
[error, restoreError],
"Package preparation failed and source artifacts could not be restored.",
{ cause: error },
);
}
export async function packOpenClawPackageForDocker(sourceDir, outputDir, options = {}) {
const runCaptureImpl = options.runCaptureImpl ?? runCapture;
const prepareChangelog =
@@ -680,13 +714,33 @@ export async function packOpenClawPackageForDocker(sourceDir, outputDir, options
allowUnreleased: options.allowUnreleasedChangelog,
}));
const restoreChangelog = options.restoreChangelog ?? restorePackageChangelog;
// Frozen refs own their package contents. Only refs carrying this lifecycle ship a generated map.
const sourceDocsMapLifecycle =
options.prepareDocsMap && options.restoreDocsMap
? null
: await loadSourceDocsMapLifecycle(sourceDir);
const prepareDocsMap =
options.prepareDocsMap ?? sourceDocsMapLifecycle?.preparePackageDocsMap ?? (async () => false);
const restoreDocsMap =
options.restoreDocsMap ?? sourceDocsMapLifecycle?.restorePackageDocsMap ?? (async () => false);
const prepareBundledAiRuntime = options.prepareBundledAiRuntime ?? prepareBundledAiRuntimePackage;
const packTool = options.pnpmPack ? "pnpm" : "npm";
if (options.packJsonPath && options.pnpmPack) {
throw new Error("packJsonPath cannot be combined with pnpmPack");
}
console.error("==> Packing OpenClaw package");
await prepareChangelog(sourceDir);
// This receipt is the package lifecycle lock; acquire it before touching CHANGELOG.md.
await prepareDocsMap(sourceDir);
try {
await prepareChangelog(sourceDir);
} catch (error) {
try {
await restorePackageSourceArtifacts(sourceDir, restoreDocsMap, restoreChangelog);
} catch (restoreError) {
throw packagePreparationRestoreError(error, restoreError);
}
throw error;
}
let packOutput;
let cleanupBundledAiRuntime = async () => {};
try {
@@ -714,7 +768,7 @@ export async function packOpenClawPackageForDocker(sourceDir, outputDir, options
try {
await cleanupBundledAiRuntime();
} finally {
await restoreChangelog(sourceDir);
await restorePackageSourceArtifacts(sourceDir, restoreDocsMap, restoreChangelog);
}
}
// pnpm reports an absolute destination path. The directory was emptied before packing,
@@ -797,7 +851,10 @@ async function main() {
process.stdout.write(`${tarball}\n`);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
if (
process.argv[1] &&
(await fs.realpath(process.argv[1])) === (await fs.realpath(fileURLToPath(import.meta.url)))
) {
await main().catch(
/** @param {unknown} error */ (error) => {
console.error(error instanceof Error ? error.message : String(error));

View File

@@ -18,6 +18,10 @@ import {
import { useAutoCleanupTempDirTracker } from "../../../helpers/temp-dir.js";
const skipBundledAiRuntime = async (): Promise<() => Promise<void>> => async () => {};
const skipDocsMapLifecycle = {
prepareDocsMap: async (): Promise<void> => {},
restoreDocsMap: async (): Promise<void> => {},
};
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
function isProcessAlive(pid: number): boolean {
@@ -229,6 +233,8 @@ describe("package-openclaw-for-docker", () => {
const copiedFiles = [
"scripts/package-openclaw-for-docker.mjs",
"scripts/package-changelog.mjs",
"scripts/package-docs-map.mjs",
"scripts/docs-list.js",
"scripts/npm-runner.mjs",
"scripts/pnpm-runner.mjs",
"scripts/windows-cmd-helpers.mjs",
@@ -294,7 +300,8 @@ describe("package-openclaw-for-docker", () => {
expect({ command, cwd }).toEqual({ command: "node", cwd: sourceDir });
expect(args).toEqual([
"--import",
pathToFileURL(path.join(sourceDir, "node_modules", "tsx", "loader.mjs")).href,
pathToFileURL(fs.realpathSync(path.join(sourceDir, "node_modules", "tsx", "loader.mjs")))
.href,
path.join(sourceDir, "scripts", "write-package-dist-inventory.ts"),
]);
fs.writeFileSync(
@@ -593,7 +600,13 @@ describe("package-openclaw-for-docker", () => {
calls.push(`prepare:${cwd}`);
},
restoreChangelog: async (cwd: string) => {
calls.push(`restore:${cwd}`);
calls.push(`restore-changelog:${cwd}`);
},
prepareDocsMap: async (cwd: string) => {
calls.push(`prepare-docs:${cwd}`);
},
restoreDocsMap: async (cwd: string) => {
calls.push(`restore-docs:${cwd}`);
},
runCaptureImpl: async (
command: string,
@@ -609,12 +622,57 @@ describe("package-openclaw-for-docker", () => {
expect(tarball).toBe(path.join("/out", "openclaw-2026.5.28.tgz"));
expect(calls).toEqual([
"prepare-docs:/repo",
"prepare:/repo",
"npm:pack --silent --ignore-scripts --pack-destination /out:/repo",
"restore:/repo",
"restore-changelog:/repo",
"restore-docs:/repo",
]);
});
it("does not touch other source artifacts when the docs-map lock fails", async () => {
const calls: string[] = [];
await expect(
packOpenClawPackageForDocker("/repo", "/out", {
prepareChangelog: async () => calls.push("prepare-changelog"),
prepareDocsMap: async () => {
calls.push("prepare-docs");
throw new Error("docs failed");
},
}),
).rejects.toThrow("docs failed");
expect(calls).toEqual(["prepare-docs"]);
});
it("keeps the docs-map lock when changelog restoration fails", async () => {
const outputDir = tempDirs.make("openclaw-package-restore-order-");
const calls: string[] = [];
await expect(
packOpenClawPackageForDocker("/repo", outputDir, {
prepareBundledAiRuntime: skipBundledAiRuntime,
prepareChangelog: async () => {},
prepareDocsMap: async () => {},
restoreChangelog: async () => {
calls.push("restore-changelog");
throw new Error("changelog restore failed");
},
restoreDocsMap: async () => {
calls.push("restore-docs");
},
runCaptureImpl: async () => {
const packedPath = path.join(outputDir, "openclaw-2026.8.1.tgz");
fs.writeFileSync(packedPath, "package");
return `${path.basename(packedPath)}\n`;
},
}),
).rejects.toThrow("changelog restore failed");
expect(calls).toEqual(["restore-changelog"]);
});
it("packages Unreleased notes for explicitly non-publish stable artifacts", async () => {
const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-unreleased-package-"));
const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-unreleased-output-"));
@@ -634,6 +692,17 @@ describe("package-openclaw-for-docker", () => {
'{"name":"openclaw","version":"2026.5.29"}\n',
);
fs.writeFileSync(path.join(sourceDir, "CHANGELOG.md"), sourceChangelog);
fs.mkdirSync(path.join(sourceDir, "docs"));
fs.writeFileSync(path.join(sourceDir, "docs", "page.md"), "# Package page\n");
fs.mkdirSync(path.join(sourceDir, "scripts"));
fs.copyFileSync(
path.join(process.cwd(), "scripts", "package-docs-map.mjs"),
path.join(sourceDir, "scripts", "package-docs-map.mjs"),
);
fs.copyFileSync(
path.join(process.cwd(), "scripts", "docs-list.js"),
path.join(sourceDir, "scripts", "docs-list.js"),
);
try {
const tarball = await packOpenClawPackageForDocker(sourceDir, outputDir, {
@@ -643,6 +712,9 @@ describe("package-openclaw-for-docker", () => {
const packagedChangelog = fs.readFileSync(path.join(sourceDir, "CHANGELOG.md"), "utf8");
expect(packagedChangelog).toContain("## Unreleased");
expect(packagedChangelog).not.toContain("## 2026.5.28");
expect(fs.readFileSync(path.join(sourceDir, "docs", "docs_map.md"), "utf8")).toContain(
"## page.md",
);
const packedPath = path.join(outputDir, "openclaw-2026.5.29.tgz");
fs.writeFileSync(packedPath, "package");
return "openclaw-2026.5.29.tgz\n";
@@ -651,6 +723,43 @@ describe("package-openclaw-for-docker", () => {
expect(tarball).toBe(path.join(outputDir, "openclaw-2026.5.29.tgz"));
expect(fs.readFileSync(path.join(sourceDir, "CHANGELOG.md"), "utf8")).toBe(sourceChangelog);
expect(fs.existsSync(path.join(sourceDir, "docs", "docs_map.md"))).toBe(false);
} finally {
fs.rmSync(sourceDir, { recursive: true, force: true });
fs.rmSync(outputDir, { recursive: true, force: true });
}
});
it("keeps a frozen pre-map source package byte-owned by that ref", async () => {
const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-frozen-package-"));
const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-frozen-output-"));
const docsDir = path.join(sourceDir, "docs");
fs.mkdirSync(docsDir);
fs.writeFileSync(
path.join(sourceDir, "package.json"),
'{"name":"openclaw","version":"2026.6.33"}\n',
);
fs.writeFileSync(
path.join(docsDir, "index.md"),
'---\nsummary: "Frozen OpenClaw docs"\n---\n\n# OpenClaw\n',
);
expect(fs.existsSync(path.join(sourceDir, "scripts", "package-docs-map.mjs"))).toBe(false);
try {
const tarball = await packOpenClawPackageForDocker(sourceDir, outputDir, {
prepareBundledAiRuntime: skipBundledAiRuntime,
prepareChangelog: async () => {},
restoreChangelog: async () => {},
runCaptureImpl: async () => {
expect(fs.existsSync(path.join(docsDir, "docs_map.md"))).toBe(false);
const packedPath = path.join(outputDir, "openclaw-2026.6.33.tgz");
fs.writeFileSync(packedPath, "frozen package");
return `${path.basename(packedPath)}\n`;
},
});
expect(tarball).toBe(path.join(outputDir, "openclaw-2026.6.33.tgz"));
expect(fs.existsSync(path.join(docsDir, "docs_map.md"))).toBe(false);
} finally {
fs.rmSync(sourceDir, { recursive: true, force: true });
fs.rmSync(outputDir, { recursive: true, force: true });
@@ -664,6 +773,7 @@ describe("package-openclaw-for-docker", () => {
try {
const tarball = await packOpenClawPackageForDocker("/repo", outputDir, {
...skipDocsMapLifecycle,
pnpmPack: true,
prepareBundledAiRuntime: skipBundledAiRuntime,
prepareChangelog: async () => {},
@@ -690,6 +800,7 @@ describe("package-openclaw-for-docker", () => {
try {
const tarball = await packOpenClawPackageForDocker("/repo", outputDir, {
...skipDocsMapLifecycle,
outputName: "openclaw-current.tgz",
packJsonPath,
prepareBundledAiRuntime: skipBundledAiRuntime,
@@ -750,6 +861,7 @@ describe("package-openclaw-for-docker", () => {
]) {
await expect(
packOpenClawPackageForDocker("/repo", "/out", {
...skipDocsMapLifecycle,
prepareBundledAiRuntime: skipBundledAiRuntime,
prepareChangelog: async () => {},
restoreChangelog: async () => {},
@@ -772,6 +884,7 @@ describe("package-openclaw-for-docker", () => {
}
await expect(
packOpenClawPackageForDocker("/repo", outputDir, {
...skipDocsMapLifecycle,
prepareBundledAiRuntime: skipBundledAiRuntime,
prepareChangelog: async () => {},
restoreChangelog: async () => {},
@@ -781,6 +894,7 @@ describe("package-openclaw-for-docker", () => {
await expect(
packOpenClawPackageForDocker("/repo", outputDir, {
...skipDocsMapLifecycle,
prepareBundledAiRuntime: skipBundledAiRuntime,
prepareChangelog: async () => {},
restoreChangelog: async () => {},
@@ -802,6 +916,7 @@ describe("package-openclaw-for-docker", () => {
await expect(
packOpenClawPackageForDocker("/repo", outputDir, {
...skipDocsMapLifecycle,
prepareBundledAiRuntime: skipBundledAiRuntime,
prepareChangelog: async () => {},
restoreChangelog: async () => {},
@@ -826,6 +941,7 @@ describe("package-openclaw-for-docker", () => {
await expect(
packOpenClawPackageForDocker("/repo", "/out", {
...skipDocsMapLifecycle,
prepareBundledAiRuntime: async () => {
calls.push("embed");
return async () => {
@@ -836,7 +952,7 @@ describe("package-openclaw-for-docker", () => {
calls.push(`prepare:${cwd}`);
},
restoreChangelog: async (cwd: string) => {
calls.push(`restore:${cwd}`);
calls.push(`restore-changelog:${cwd}`);
},
runCaptureImpl: async () => {
calls.push("pack");
@@ -845,7 +961,7 @@ describe("package-openclaw-for-docker", () => {
}),
).rejects.toThrow("pack failed");
expect(calls).toEqual(["prepare:/repo", "embed", "pack", "cleanup", "restore:/repo"]);
expect(calls).toEqual(["prepare:/repo", "embed", "pack", "cleanup", "restore-changelog:/repo"]);
});
it("clamps oversized command timers before scheduling", async () => {

View File

@@ -1,8 +1,9 @@
// docs-list tests cover source docs metadata discovery for docs-aware tooling.
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { renderDocsHeadingMap } from "../../scripts/docs-list.js";
import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js";
const tempDirs: string[] = [];
@@ -13,8 +14,8 @@ function makeTempRepoRoot(prefix: string): string {
return makeTempDir(tempDirs, prefix);
}
function runDocsList(cwd: string): string {
return execFileSync(process.execPath, [docsListScriptPath], {
function runDocsList(cwd: string, args: string[] = []): string {
return execFileSync(process.execPath, [docsListScriptPath, ...args], {
cwd,
encoding: "utf8",
});
@@ -25,6 +26,17 @@ afterEach(() => {
});
describe("docs-list", () => {
it("reports a concise error outside a source checkout", () => {
const tempRepoRoot = makeTempRepoRoot("openclaw-docs-list-missing-");
const result = spawnSync(process.execPath, [docsListScriptPath], {
cwd: tempRepoRoot,
encoding: "utf8",
});
expect(result.status).toBe(1);
expect(result.stderr).toBe("docs:list: missing docs directory. Run from repo root.\n");
});
it("prints single-line read_when strings as read hints", () => {
const tempRepoRoot = makeTempRepoRoot("openclaw-docs-list-");
mkdirSync(path.join(tempRepoRoot, "docs"), { recursive: true });
@@ -43,4 +55,58 @@ read_when: "Read this page when the hint is inline."
expect(output).toContain("page.md - Single-line read_when page");
expect(output).toContain("Read when: Read this page when the hint is inline.");
});
it("renders the publish docs map on demand without creating a mirror", () => {
const tempRepoRoot = makeTempRepoRoot("openclaw-docs-headings-");
mkdirSync(path.join(tempRepoRoot, "docs", "nested"), { recursive: true });
writeFileSync(
path.join(tempRepoRoot, "docs", "page.md"),
`---
summary: "Page"
---
# Visible title
## \`API[*]\` <script>alert(1)</script>
### <scr<script>ipt>alert(1)</script>
#### \`![label](https://example.test/image)\`
\`\`\`md
### Hidden fenced heading
\`\`\`
`,
"utf8",
);
writeFileSync(path.join(tempRepoRoot, "docs", "nested", "index.mdx"), "# Nested\n");
writeFileSync(path.join(tempRepoRoot, "docs", "AGENTS.md"), "# Instructions\n");
const output = runDocsList(tempRepoRoot, ["--headings"]);
expect(output).toContain("## page.md\n\n- Route: /page");
expect(output).toContain(" - H1: Visible title");
expect(output).toContain(" - H2: `API[*]` &lt;script&gt;alert(1)&lt;/script&gt;");
expect(output).toContain(" - H3: &lt;scr&lt;script&gt;ipt&gt;alert(1)&lt;/script&gt;");
expect(output).toContain(" - H4: `![label](https://example.test/image)`");
expect(output).toContain("## nested/index.mdx\n\n- Route: /nested");
expect(output).not.toContain("Hidden fenced heading");
expect(output).not.toContain("AGENTS.md");
expect(existsSync(path.join(tempRepoRoot, "docs", "docs_map.md"))).toBe(false);
});
it("normalizes injected Windows paths for nested page routes", () => {
const tempRepoRoot = makeTempRepoRoot("openclaw-docs-headings-windows-");
const docsDir = path.join(tempRepoRoot, "docs");
mkdirSync(path.join(docsDir, "nested"), { recursive: true });
writeFileSync(path.join(docsDir, "nested", "index.mdx"), "# Nested index\n");
writeFileSync(path.join(docsDir, "nested", "page.md"), "# Nested page\n");
const output = renderDocsHeadingMap(docsDir, {
relativePath: (base, fullPath) => path.relative(base, fullPath).replace(/[\\/]+/gu, "\\"),
});
expect(output).toContain("## nested/index.mdx\n\n- Route: /nested");
expect(output).toContain("## nested/page.md\n\n- Route: /nested/page");
expect(output).not.toContain("\\");
});
});

View File

@@ -2,10 +2,12 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { renderDocsHeadingMap } from "../../scripts/docs-list.js";
import {
composeDocsConfig,
parseArgs,
reportOrphanLocaleDocs,
writePublishedDocsMap,
} from "../../scripts/docs-sync-publish.mjs";
function collectPages(entry: unknown, pages: string[] = []): string[] {
@@ -33,6 +35,18 @@ function collectPages(entry: unknown, pages: string[] = []): string[] {
}
describe("docs-sync-publish", () => {
it("materializes the public docs map only in the publish tree", () => {
const targetDocsDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-docs-map-publish-"));
try {
const outputPath = writePublishedDocsMap(targetDocsDir);
expect(fs.readFileSync(outputPath, "utf8")).toBe(
renderDocsHeadingMap(path.resolve(import.meta.dirname, "../../docs")),
);
} finally {
fs.rmSync(targetDocsDir, { recursive: true, force: true });
}
});
it("parses docs sync provenance args", () => {
expect(
parseArgs([

View File

@@ -1,22 +0,0 @@
import { describe, expect, it } from "vitest";
import { testing } from "../../scripts/generate-docs-map.mjs";
describe("generate docs map", () => {
it("renders heading HTML as text", () => {
expect(testing.cleanHeadingText("`API` <script>alert(1)</script>")).toBe(
"API &lt;script&gt;alert(1)&lt;/script&gt;",
);
expect(testing.cleanHeadingText("<scr<script>ipt>alert(1)</script>")).toBe(
"&lt;scr&lt;script&gt;ipt&gt;alert(1)&lt;/script&gt;",
);
});
it("preserves Markdown syntax inside inline code", () => {
expect(testing.cleanHeadingText("`agents.entries.*.contextLimits` and *defaults*")).toBe(
"`agents.entries.*.contextLimits` and defaults",
);
expect(testing.cleanHeadingText("`![label](https://example.test/image)`")).toBe(
"`![label](https://example.test/image)`",
);
});
});

View File

@@ -1,5 +1,5 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -11,12 +11,24 @@ import {
resolveRuntimePackEnvironment,
resolveRuntimePackPlan,
resolveWorkspaceInstallPlan,
restoreRuntimePack,
rewriteWorkspaceDependencyVersions,
runPreparedRuntimePack,
} from "../../scripts/ocm-npm-workspace-deps.mjs";
import { restorePrepackArtifacts } from "../../scripts/openclaw-postpack.mjs";
import { preparePackageChangelog } from "../../scripts/package-changelog.mjs";
import { preparePackageDocsMap } from "../../scripts/package-docs-map.mjs";
const adapterPath = fileURLToPath(
new URL("../../scripts/ocm-npm-workspace-deps.mjs", import.meta.url),
);
const packageDocsMapPath = fileURLToPath(
new URL("../../scripts/package-docs-map.mjs", import.meta.url),
);
const packageChangelogPath = fileURLToPath(
new URL("../../scripts/package-changelog.mjs", import.meta.url),
);
const postpackPath = fileURLToPath(new URL("../../scripts/openclaw-postpack.mjs", import.meta.url));
describe("OCM npm workspace dependency adapter", () => {
it("allows Unreleased notes only for non-publishing pack commands", () => {
@@ -81,6 +93,106 @@ describe("OCM npm workspace dependency adapter", () => {
).toThrow("runtime pack commit must be a full 40-character hexadecimal SHA");
});
it("does not let a failed concurrent owner restore the active pack lifecycle", async () => {
const root = mkdtempSync(join(tmpdir(), "openclaw-ocm-pack-owner-"));
const docsDir = join(root, "docs");
const mapPath = join(docsDir, "docs_map.md");
const receiptPath = join(root, ".artifacts", "package-docs-map", "receipt.json");
const changelogBackupPath = join(
root,
".artifacts",
"package-changelog",
"CHANGELOG.md.prepack-backup",
);
const sourceMap = "# Docs map source\n";
const sourceChangelog = `# Changelog
## 2026.8.1
- Current release notes with enough detail for package validation.
## 2026.7.1
- Previous release notes with enough detail for package validation.
`;
mkdirSync(docsDir, { recursive: true });
writeFileSync(join(docsDir, "page.md"), "# Package docs\n");
writeFileSync(mapPath, sourceMap);
writeFileSync(join(root, "package.json"), '{"name":"openclaw","version":"2026.8.1"}\n');
writeFileSync(join(root, "CHANGELOG.md"), sourceChangelog);
try {
await preparePackageDocsMap(root);
await preparePackageChangelog(root);
const activeMap = readFileSync(mapPath, "utf8");
const activeChangelog = readFileSync(join(root, "CHANGELOG.md"), "utf8");
let packCalled = false;
expect(() =>
runPreparedRuntimePack(
() =>
execFileSync(process.execPath, [packageDocsMapPath, "prepare"], {
cwd: root,
stdio: "pipe",
}),
() => {
packCalled = true;
return 0;
},
() => execFileSync(process.execPath, [postpackPath], { cwd: root, stdio: "pipe" }),
),
).toThrow();
expect(packCalled).toBe(false);
expect(existsSync(receiptPath)).toBe(true);
expect(existsSync(changelogBackupPath)).toBe(true);
expect(readFileSync(mapPath, "utf8")).toBe(activeMap);
expect(readFileSync(join(root, "CHANGELOG.md"), "utf8")).toBe(activeChangelog);
await restorePrepackArtifacts(root);
expect(readFileSync(mapPath, "utf8")).toBe(sourceMap);
expect(readFileSync(join(root, "CHANGELOG.md"), "utf8")).toBe(sourceChangelog);
} finally {
rmSync(root, { force: true, recursive: true });
}
});
it("restores a prepared legacy source fixture through its changelog owner", () => {
const root = mkdtempSync(join(tmpdir(), "openclaw-ocm-historical-pack-"));
const scriptsDir = join(root, "scripts");
const sourceChangelog = `# Changelog
## 2026.8.1
- Current release notes with enough detail for package validation.
## 2026.7.1
- Previous release notes with enough detail for package validation.
`;
mkdirSync(scriptsDir, { recursive: true });
writeFileSync(join(root, "package.json"), '{"name":"openclaw","version":"2026.8.1"}\n');
writeFileSync(join(root, "CHANGELOG.md"), sourceChangelog);
try {
writeFileSync(
join(scriptsDir, "package-changelog.mjs"),
readFileSync(packageChangelogPath, "utf8"),
);
execFileSync(process.execPath, ["scripts/package-changelog.mjs", "prepare"], {
cwd: root,
stdio: "pipe",
});
expect(readFileSync(join(root, "CHANGELOG.md"), "utf8")).not.toBe(sourceChangelog);
expect(existsSync(join(root, "scripts", "openclaw-postpack.mjs"))).toBe(false);
restoreRuntimePack(process.env, root);
expect(readFileSync(join(root, "CHANGELOG.md"), "utf8")).toBe(sourceChangelog);
expect(
existsSync(join(root, ".artifacts", "package-changelog", "CHANGELOG.md.prepack-backup")),
).toBe(false);
} finally {
rmSync(root, { force: true, recursive: true });
}
});
it("resolves workspace package directories", () => {
expect(
parseWorkspaceDependencyDirs(["packages/ai", "extensions/example"].join(delimiter), "/repo"),

View File

@@ -0,0 +1,131 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { renderDocsHeadingMap } from "../../scripts/docs-list.js";
import { restorePrepackArtifacts } from "../../scripts/openclaw-postpack.mjs";
import { preparePackageChangelog } from "../../scripts/package-changelog.mjs";
import { preparePackageDocsMap, restorePackageDocsMap } from "../../scripts/package-docs-map.mjs";
import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js";
const tempDirs: string[] = [];
const sourceChangelog = `# Changelog
## 2026.8.1
- Current release notes with enough detail for package validation.
## 2026.7.1
- Previous release notes with enough detail for package validation.
`;
function makePackageRoot(): string {
const root = makeTempDir(tempDirs, "openclaw-package-docs-map-");
mkdirSync(path.join(root, "docs"), { recursive: true });
writeFileSync(path.join(root, "docs", "page.md"), "# Package docs\n", "utf8");
writeFileSync(path.join(root, "package.json"), '{"name":"openclaw","version":"2026.8.1"}\n');
writeFileSync(path.join(root, "CHANGELOG.md"), sourceChangelog);
return root;
}
afterEach(() => {
cleanupTempDirs(tempDirs);
});
describe("package docs map", () => {
it("materializes exact generated bytes and removes only its transient copy", async () => {
const root = makePackageRoot();
const mapPath = path.join(root, "docs", "docs_map.md");
await expect(preparePackageDocsMap(root)).resolves.toBe(true);
expect(readFileSync(mapPath, "utf8")).toBe(renderDocsHeadingMap(path.join(root, "docs")));
await expect(restorePackageDocsMap(root)).resolves.toBe(true);
expect(existsSync(mapPath)).toBe(false);
});
it("preserves an identical map that existed before packaging", async () => {
const root = makePackageRoot();
const mapPath = path.join(root, "docs", "docs_map.md");
const content = renderDocsHeadingMap(path.join(root, "docs"));
writeFileSync(mapPath, content, "utf8");
await expect(preparePackageDocsMap(root)).resolves.toBe(false);
await expect(restorePackageDocsMap(root)).resolves.toBe(false);
expect(readFileSync(mapPath, "utf8")).toBe(content);
});
it("restores a tracked source stub after packaging", async () => {
const root = makePackageRoot();
const mapPath = path.join(root, "docs", "docs_map.md");
const stub = "# Docs map source\n";
writeFileSync(mapPath, stub, "utf8");
await expect(preparePackageDocsMap(root)).resolves.toBe(true);
expect(readFileSync(mapPath, "utf8")).toBe(renderDocsHeadingMap(path.join(root, "docs")));
await expect(restorePackageDocsMap(root)).resolves.toBe(true);
expect(readFileSync(mapPath, "utf8")).toBe(stub);
});
it("serializes concurrent package preparations with the receipt", async () => {
const root = makePackageRoot();
const mapPath = path.join(root, "docs", "docs_map.md");
const stub = "# Docs map source\n";
writeFileSync(mapPath, stub, "utf8");
const results = await Promise.allSettled([
preparePackageDocsMap(root),
preparePackageDocsMap(root),
]);
expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
const rejected = results.find((result) => result.status === "rejected");
expect(rejected).toMatchObject({
reason: expect.objectContaining({
code: "PACKAGE_DOCS_MAP_ACTIVE",
message: expect.stringContaining("node scripts/openclaw-postpack.mjs"),
}),
});
expect(readFileSync(mapPath, "utf8")).toBe(renderDocsHeadingMap(path.join(root, "docs")));
await expect(restorePackageDocsMap(root)).resolves.toBe(true);
expect(readFileSync(mapPath, "utf8")).toBe(stub);
});
it("refuses to remove a transient map changed after prepack", async () => {
const root = makePackageRoot();
const mapPath = path.join(root, "docs", "docs_map.md");
await preparePackageDocsMap(root);
writeFileSync(mapPath, "operator change\n", "utf8");
await expect(restorePackageDocsMap(root)).rejects.toThrow("changed after prepack");
expect(readFileSync(mapPath, "utf8")).toBe("operator change\n");
});
it("keeps the lifecycle lock until interrupted changelog state is restored", async () => {
const root = makePackageRoot();
const mapPath = path.join(root, "docs", "docs_map.md");
const receiptPath = path.join(root, ".artifacts", "package-docs-map", "receipt.json");
const changelogBackupPath = path.join(
root,
".artifacts",
"package-changelog",
"CHANGELOG.md.prepack-backup",
);
const stub = "# Docs map source\n";
writeFileSync(mapPath, stub);
await preparePackageDocsMap(root);
await preparePackageChangelog(root);
const packagedChangelog = readFileSync(path.join(root, "CHANGELOG.md"), "utf8");
writeFileSync(path.join(root, "CHANGELOG.md"), "operator change\n");
await expect(restorePrepackArtifacts(root)).rejects.toThrow("changed since the backup");
expect(existsSync(receiptPath)).toBe(true);
expect(existsSync(changelogBackupPath)).toBe(true);
expect(readFileSync(mapPath, "utf8")).toBe(renderDocsHeadingMap(path.join(root, "docs")));
writeFileSync(path.join(root, "CHANGELOG.md"), packagedChangelog);
await expect(restorePrepackArtifacts(root)).resolves.toBeUndefined();
expect(readFileSync(path.join(root, "CHANGELOG.md"), "utf8")).toBe(sourceChangelog);
expect(readFileSync(mapPath, "utf8")).toBe(stub);
expect(existsSync(changelogBackupPath)).toBe(false);
expect(existsSync(receiptPath)).toBe(false);
});
});