Files
openclaw/scripts/write-cli-startup-metadata.ts
2026-04-04 07:59:32 +01:00

308 lines
9.6 KiB
TypeScript

import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import type { RootHelpRenderOptions } from "../src/cli/program/root-help.js";
import type { OpenClawConfig } from "../src/config/config.js";
function dedupe(values: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const value of values) {
if (!value || seen.has(value)) {
continue;
}
seen.add(value);
out.push(value);
}
return out;
}
const scriptPath = fileURLToPath(import.meta.url);
const scriptDir = path.dirname(scriptPath);
const rootDir = path.resolve(scriptDir, "..");
const distDir = path.join(rootDir, "dist");
const outputPath = path.join(distDir, "cli-startup-metadata.json");
const extensionsDir = path.join(rootDir, "extensions");
const CORE_CHANNEL_ORDER = [
"telegram",
"whatsapp",
"discord",
"irc",
"googlechat",
"slack",
"signal",
"imessage",
] as const;
type ExtensionChannelEntry = {
id: string;
order: number;
label: string;
};
type BundledChannelCatalog = {
ids: string[];
signature: string;
};
type RootHelpRenderContext = Pick<RootHelpRenderOptions, "config" | "env">;
function resolveRootHelpBundleIdentity(
distDirOverride: string = distDir,
): { bundleName: string; signature: string } | null {
const bundleName = readdirSync(distDirOverride).find(
(entry) =>
entry.startsWith("root-help-") &&
!entry.startsWith("root-help-metadata-") &&
entry.endsWith(".js"),
);
if (!bundleName) {
return null;
}
const bundlePath = path.join(distDirOverride, bundleName);
const raw = readFileSync(bundlePath, "utf8");
return {
bundleName,
signature: createHash("sha1").update(raw).digest("hex"),
};
}
export function readBundledChannelCatalog(
extensionsDirOverride: string = extensionsDir,
): BundledChannelCatalog {
const entries: ExtensionChannelEntry[] = [];
const signature = createHash("sha1");
for (const dirEntry of readdirSync(extensionsDirOverride, { withFileTypes: true })) {
if (!dirEntry.isDirectory()) {
continue;
}
const packageJsonPath = path.join(extensionsDirOverride, dirEntry.name, "package.json");
try {
const raw = readFileSync(packageJsonPath, "utf8");
signature.update(`${dirEntry.name}\0${raw}\0`);
const parsed = JSON.parse(raw) as {
openclaw?: {
channel?: {
id?: unknown;
order?: unknown;
label?: unknown;
};
};
};
const id = parsed.openclaw?.channel?.id;
if (typeof id !== "string" || !id.trim()) {
continue;
}
const orderRaw = parsed.openclaw?.channel?.order;
const labelRaw = parsed.openclaw?.channel?.label;
entries.push({
id: id.trim(),
order: typeof orderRaw === "number" ? orderRaw : 999,
label: typeof labelRaw === "string" ? labelRaw : id.trim(),
});
} catch {
// Ignore malformed or missing extension package manifests.
}
}
return {
ids: entries
.toSorted((a, b) =>
a.order === b.order ? a.label.localeCompare(b.label) : a.order - b.order,
)
.map((entry) => entry.id),
signature: signature.digest("hex"),
};
}
export function readBundledChannelCatalogIds(
extensionsDirOverride: string = extensionsDir,
): string[] {
return readBundledChannelCatalog(extensionsDirOverride).ids;
}
function createIsolatedRootHelpRenderContext(
bundledPluginsDir: string = extensionsDir,
): RootHelpRenderContext {
const stateDir = path.join(rootDir, ".openclaw-build-root-help");
const workspaceDir = path.join(stateDir, "workspace");
const homeDir = path.join(stateDir, "home");
const env: NodeJS.ProcessEnv = {
HOME: homeDir,
LOGNAME: process.env.LOGNAME ?? process.env.USER ?? "openclaw-build",
USER: process.env.USER ?? process.env.LOGNAME ?? "openclaw-build",
PATH: process.env.PATH ?? "",
TMPDIR: process.env.TMPDIR ?? "/tmp",
LANG: process.env.LANG ?? "C.UTF-8",
LC_ALL: process.env.LC_ALL ?? "C.UTF-8",
TERM: process.env.TERM ?? "dumb",
NO_COLOR: "1",
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledPluginsDir,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "",
OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1",
OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1",
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "0",
OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: "0",
OPENCLAW_STATE_DIR: stateDir,
};
const config: OpenClawConfig = {
agents: {
defaults: {
workspace: workspaceDir,
},
},
plugins: {
loadPaths: [],
},
};
return { config, env };
}
export async function renderBundledRootHelpText(
_distDirOverride: string = distDir,
renderContext: RootHelpRenderContext = createIsolatedRootHelpRenderContext(
existsSync(path.join(_distDirOverride, "extensions"))
? path.join(_distDirOverride, "extensions")
: extensionsDir,
),
): Promise<string> {
const bundleIdentity = resolveRootHelpBundleIdentity(_distDirOverride);
if (!bundleIdentity) {
throw new Error("No root-help bundle found in dist; cannot write CLI startup metadata.");
}
const moduleUrl = pathToFileURL(path.join(_distDirOverride, bundleIdentity.bundleName)).href;
const renderOptions = {
config: renderContext.config,
env: renderContext.env,
} satisfies RootHelpRenderOptions;
const inlineModule = [
`const mod = await import(${JSON.stringify(moduleUrl)});`,
"if (typeof mod.outputRootHelp !== 'function') {",
` throw new Error(${JSON.stringify(`Bundle ${bundleIdentity.bundleName} does not export outputRootHelp.`)});`,
"}",
`await mod.outputRootHelp(${JSON.stringify(renderOptions)});`,
"process.exit(0);",
].join("\n");
const result = spawnSync(process.execPath, ["--input-type=module", "--eval", inlineModule], {
cwd: _distDirOverride,
encoding: "utf8",
env: renderContext.env,
timeout: 30_000,
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
const stderr = result.stderr?.trim();
throw new Error(
`Failed to render bundled root help from ${bundleIdentity.bundleName}` +
(stderr ? `: ${stderr}` : result.signal ? `: terminated by ${result.signal}` : ""),
);
}
return result.stdout ?? "";
}
function renderSourceRootHelpText(
renderContext: RootHelpRenderContext = createIsolatedRootHelpRenderContext(),
): string {
const moduleUrl = pathToFileURL(path.join(rootDir, "src/cli/program/root-help.ts")).href;
const renderOptions = {
pluginSdkResolution: "src",
config: renderContext.config,
env: renderContext.env,
} satisfies RootHelpRenderOptions;
const inlineModule = [
`const mod = await import(${JSON.stringify(moduleUrl)});`,
"if (typeof mod.renderRootHelpText !== 'function') {",
` throw new Error(${JSON.stringify("Source root-help module does not export renderRootHelpText.")});`,
"}",
`const output = await mod.renderRootHelpText(${JSON.stringify(renderOptions)});`,
"process.stdout.write(output);",
"process.exit(0);",
].join("\n");
const result = spawnSync(
process.execPath,
["--import", "tsx", "--input-type=module", "--eval", inlineModule],
{
cwd: rootDir,
encoding: "utf8",
env: renderContext.env,
timeout: 30_000,
},
);
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
const stderr = result.stderr?.trim();
throw new Error(
"Failed to render source root help" +
(stderr ? `: ${stderr}` : result.signal ? `: terminated by ${result.signal}` : ""),
);
}
return result.stdout ?? "";
}
export async function writeCliStartupMetadata(options?: {
distDir?: string;
outputPath?: string;
extensionsDir?: string;
}): Promise<void> {
const resolvedDistDir = options?.distDir ?? distDir;
const resolvedOutputPath = options?.outputPath ?? outputPath;
const resolvedExtensionsDir = options?.extensionsDir ?? extensionsDir;
const channelCatalog = readBundledChannelCatalog(resolvedExtensionsDir);
const bundleIdentity = resolveRootHelpBundleIdentity(resolvedDistDir);
const bundledPluginsDir = path.join(resolvedDistDir, "extensions");
const renderContext = createIsolatedRootHelpRenderContext(
existsSync(bundledPluginsDir) ? bundledPluginsDir : resolvedExtensionsDir,
);
const channelOptions = dedupe([...CORE_CHANNEL_ORDER, ...channelCatalog.ids]);
try {
const existing = JSON.parse(readFileSync(resolvedOutputPath, "utf8")) as {
rootHelpBundleSignature?: unknown;
channelCatalogSignature?: unknown;
};
if (
bundleIdentity &&
existing.rootHelpBundleSignature === bundleIdentity.signature &&
existing.channelCatalogSignature === channelCatalog.signature
) {
return;
}
} catch {
// Missing or malformed existing metadata means we should regenerate it.
}
let rootHelpText: string;
try {
rootHelpText = await renderBundledRootHelpText(resolvedDistDir, renderContext);
} catch {
rootHelpText = renderSourceRootHelpText(renderContext);
}
mkdirSync(resolvedDistDir, { recursive: true });
writeFileSync(
resolvedOutputPath,
`${JSON.stringify(
{
generatedBy: "scripts/write-cli-startup-metadata.ts",
channelOptions,
channelCatalogSignature: channelCatalog.signature,
rootHelpBundleSignature: bundleIdentity?.signature ?? null,
rootHelpText,
},
null,
2,
)}\n`,
"utf8",
);
}
if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) {
await writeCliStartupMetadata();
process.exit(0);
}