diff --git a/scripts/ensure-cli-startup-build.mjs b/scripts/ensure-cli-startup-build.mjs index 8f90788abfc..4c4060a998d 100644 --- a/scripts/ensure-cli-startup-build.mjs +++ b/scripts/ensure-cli-startup-build.mjs @@ -7,11 +7,13 @@ import { fileURLToPath, pathToFileURL } from "node:url"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const entryCandidates = ["dist/entry.js", "dist/entry.mjs"]; +const startupMetadataPath = "dist/cli-startup-metadata.json"; export function hasCliStartupBuild(params = {}) { const rootDir = params.rootDir ?? repoRoot; const exists = params.existsSync ?? existsSync; - return entryCandidates.some((relativePath) => exists(path.join(rootDir, relativePath))); + const hasEntry = entryCandidates.some((relativePath) => exists(path.join(rootDir, relativePath))); + return hasEntry && exists(path.join(rootDir, startupMetadataPath)); } export function ensureCliStartupBuild(params = {}) { @@ -24,7 +26,9 @@ export function ensureCliStartupBuild(params = {}) { const spawn = params.spawnSync ?? spawnSync; const buildScript = path.join(rootDir, "scripts", "build-all.mjs"); - console.error("[cli-startup-build] dist/entry missing; running cliStartup build profile"); + console.error( + "[cli-startup-build] dist startup entry or metadata missing; running cliStartup build profile", + ); const result = spawn(nodeExecPath, [buildScript, "cliStartup"], { cwd: rootDir, env: params.env ?? process.env, diff --git a/scripts/ensure-extension-memory-build.mjs b/scripts/ensure-extension-memory-build.mjs new file mode 100644 index 00000000000..733bd7493ce --- /dev/null +++ b/scripts/ensure-extension-memory-build.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { existsSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { + collectBundledPluginBuildEntries, + NON_PACKAGED_BUNDLED_PLUGIN_DIRS, +} from "./lib/bundled-plugin-build-entries.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function collectExpectedExtensionMemoryEntryIds(rootDir, env) { + try { + const entries = collectBundledPluginBuildEntries({ cwd: rootDir, env }); + return entries + .filter( + (entry) => + entry.hasManifest && + !NON_PACKAGED_BUNDLED_PLUGIN_DIRS.has(entry.id) && + entry.sourceEntries.length > 0, + ) + .map((entry) => entry.id) + .toSorted((left, right) => left.localeCompare(right)); + } catch { + return null; + } +} + +export function hasBuiltExtensionMemoryEntries(params = {}) { + const rootDir = params.rootDir ?? repoRoot; + const exists = params.existsSync ?? existsSync; + const readDir = params.readdirSync ?? readdirSync; + const extensionsDir = path.join(rootDir, "dist", "extensions"); + if (!exists(extensionsDir)) { + return false; + } + let extensionIds; + try { + extensionIds = readDir(extensionsDir); + } catch { + return false; + } + const requiredExtensionIds = + params.requiredExtensionIds?.length > 0 + ? params.requiredExtensionIds + : collectExpectedExtensionMemoryEntryIds(rootDir, params.env ?? process.env); + if (!requiredExtensionIds || requiredExtensionIds.length === 0) { + return extensionIds.some((extensionId) => + exists(path.join(extensionsDir, extensionId, "index.js")), + ); + } + return requiredExtensionIds.every((extensionId) => + exists(path.join(extensionsDir, extensionId, "index.js")), + ); +} + +export function ensureExtensionMemoryBuild(params = {}) { + const rootDir = params.rootDir ?? repoRoot; + if ( + hasBuiltExtensionMemoryEntries({ + rootDir, + existsSync: params.existsSync, + readdirSync: params.readdirSync, + requiredExtensionIds: params.requiredExtensionIds, + env: params.env, + }) + ) { + return { built: false }; + } + + const nodeExecPath = params.nodeExecPath ?? process.execPath; + const spawn = params.spawnSync ?? spawnSync; + const buildScript = path.join(rootDir, "scripts", "build-all.mjs"); + + console.error( + "[extension-memory-build] dist/extensions missing; running cliStartup build profile", + ); + const result = spawn(nodeExecPath, [buildScript, "cliStartup"], { + cwd: rootDir, + env: params.env ?? process.env, + stdio: params.stdio ?? "inherit", + }); + if (result.error) { + throw result.error; + } + const status = result.status ?? (result.signal ? 1 : 0); + if (status !== 0) { + throw new Error(`cliStartup build profile failed with exit code ${status}`); + } + return { built: true }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + ensureExtensionMemoryBuild(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/profile-extension-memory.mjs b/scripts/profile-extension-memory.mjs index 24a8dc486e2..77e5a68c13a 100644 --- a/scripts/profile-extension-memory.mjs +++ b/scripts/profile-extension-memory.mjs @@ -4,6 +4,7 @@ import { spawn } from "node:child_process"; import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; +import { ensureExtensionMemoryBuild } from "./ensure-extension-memory-build.mjs"; import { formatErrorMessage } from "./lib/error-format.mjs"; const DEFAULT_CONCURRENCY = 6; @@ -241,6 +242,10 @@ function findExtensionEntries(repoRoot) { async function main() { const options = parseArgs(process.argv.slice(2)); const repoRoot = process.cwd(); + ensureExtensionMemoryBuild({ + rootDir: repoRoot, + requiredExtensionIds: options.extensions, + }); const allEntries = findExtensionEntries(repoRoot); const selectedEntries = options.extensions.length === 0 diff --git a/test/scripts/ensure-cli-startup-build.test.ts b/test/scripts/ensure-cli-startup-build.test.ts index e879c357efc..95bc853a63c 100644 --- a/test/scripts/ensure-cli-startup-build.test.ts +++ b/test/scripts/ensure-cli-startup-build.test.ts @@ -28,14 +28,24 @@ describe("ensure-cli-startup-build", () => { const root = makeTempRoot(); mkdirSync(path.join(root, "dist"), { recursive: true }); writeFileSync(path.join(root, "dist", "entry.js"), "export {};\n", "utf8"); + writeFileSync( + path.join(root, "dist", "cli-startup-metadata.json"), + '{"rootHelpText":"help\\n"}\n', + "utf8", + ); expect(hasCliStartupBuild({ rootDir: root })).toBe(true); }); - it("skips the build profile when dist entry output already exists", () => { + it("skips the build profile when dist entry output and startup metadata already exist", () => { const root = makeTempRoot(); mkdirSync(path.join(root, "dist"), { recursive: true }); writeFileSync(path.join(root, "dist", "entry.mjs"), "export {};\n", "utf8"); + writeFileSync( + path.join(root, "dist", "cli-startup-metadata.json"), + '{"rootHelpText":"help\\n"}\n', + "utf8", + ); const result = ensureCliStartupBuild({ rootDir: root, @@ -47,6 +57,35 @@ describe("ensure-cli-startup-build", () => { expect(result).toEqual({ built: false }); }); + it("runs the cliStartup build profile when startup metadata is missing", () => { + const root = makeTempRoot(); + mkdirSync(path.join(root, "dist"), { recursive: true }); + writeFileSync(path.join(root, "dist", "entry.js"), "export {};\n", "utf8"); + const calls: unknown[] = []; + + const result = ensureCliStartupBuild({ + rootDir: root, + nodeExecPath: "/node", + spawnSync: (command, args, options) => { + calls.push({ command, args, options }); + return { status: 0 }; + }, + stdio: "pipe", + }); + + expect(result).toEqual({ built: true }); + expect(calls).toEqual([ + { + command: "/node", + args: [path.join(root, "scripts", "build-all.mjs"), "cliStartup"], + options: expect.objectContaining({ + cwd: root, + stdio: "pipe", + }), + }, + ]); + }); + it("runs the cliStartup build profile when dist entry output is missing", () => { const root = makeTempRoot(); const calls: unknown[] = []; diff --git a/test/scripts/ensure-extension-memory-build.test.ts b/test/scripts/ensure-extension-memory-build.test.ts new file mode 100644 index 00000000000..dd4e42b3f3f --- /dev/null +++ b/test/scripts/ensure-extension-memory-build.test.ts @@ -0,0 +1,117 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + ensureExtensionMemoryBuild, + hasBuiltExtensionMemoryEntries, +} from "../../scripts/ensure-extension-memory-build.mjs"; + +const tempRoots: string[] = []; + +function makeTempRoot(): string { + const root = mkdtempSync(path.join(tmpdir(), "openclaw-extension-memory-build-")); + tempRoots.push(root); + mkdirSync(path.join(root, "scripts"), { recursive: true }); + writeFileSync(path.join(root, "scripts", "build-all.mjs"), "", "utf8"); + return root; +} + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe("ensure-extension-memory-build", () => { + it("detects existing built extension entrypoints", () => { + const root = makeTempRoot(); + mkdirSync(path.join(root, "dist", "extensions", "telegram"), { recursive: true }); + writeFileSync( + path.join(root, "dist", "extensions", "telegram", "index.js"), + "export {};\n", + "utf8", + ); + + expect( + hasBuiltExtensionMemoryEntries({ rootDir: root, requiredExtensionIds: ["telegram"] }), + ).toBe(true); + }); + + it("rejects partial built extension entrypoint sets", () => { + const root = makeTempRoot(); + mkdirSync(path.join(root, "dist", "extensions", "discord"), { recursive: true }); + writeFileSync( + path.join(root, "dist", "extensions", "discord", "index.js"), + "export {};\n", + "utf8", + ); + + expect( + hasBuiltExtensionMemoryEntries({ + rootDir: root, + requiredExtensionIds: ["discord", "telegram"], + }), + ).toBe(false); + }); + + it("skips the build profile when extension entrypoints already exist", () => { + const root = makeTempRoot(); + mkdirSync(path.join(root, "dist", "extensions", "discord"), { recursive: true }); + writeFileSync( + path.join(root, "dist", "extensions", "discord", "index.js"), + "export {};\n", + "utf8", + ); + + const result = ensureExtensionMemoryBuild({ + rootDir: root, + requiredExtensionIds: ["discord"], + spawnSync: () => { + throw new Error("unexpected build"); + }, + }); + + expect(result).toEqual({ built: false }); + }); + + it("runs the cliStartup build profile when extension entrypoints are missing", () => { + const root = makeTempRoot(); + const calls: unknown[] = []; + + const result = ensureExtensionMemoryBuild({ + rootDir: root, + requiredExtensionIds: ["discord"], + nodeExecPath: "/node", + spawnSync: (command, args, options) => { + calls.push({ command, args, options }); + return { status: 0 }; + }, + stdio: "pipe", + }); + + expect(result).toEqual({ built: true }); + expect(calls).toEqual([ + { + command: "/node", + args: [path.join(root, "scripts", "build-all.mjs"), "cliStartup"], + options: expect.objectContaining({ + cwd: root, + stdio: "pipe", + }), + }, + ]); + }); + + it("fails when the cliStartup build profile fails", () => { + const root = makeTempRoot(); + + expect(() => + ensureExtensionMemoryBuild({ + rootDir: root, + spawnSync: () => ({ status: 1 }), + stdio: "pipe", + }), + ).toThrow("cliStartup build profile failed with exit code 1"); + }); +});