diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f77551f4f8..72069e7b364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ Docs: https://docs.openclaw.ai - CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc. - Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. - Gateway/watch mode: restart on bundled-plugin package and manifest metadata changes, rebuild `dist` for extension source and `tsdown.config.ts` changes, and still ignore extension docs. (#47571) thanks @gumadeiras. +- Gateway/watch mode: recreate bundled plugin runtime metadata after clean or stale `dist` states, so `pnpm gateway:watch` no longer fails on missing `dist/extensions/*/openclaw.plugin.json` manifests after a rebuild. Thanks @gumadeiras. ## 2026.3.13 diff --git a/package.json b/package.json index a839cdd3ec1..d8f1e530d9b 100644 --- a/package.json +++ b/package.json @@ -225,10 +225,10 @@ "android:run": "cd apps/android && ./gradlew :app:installDebug && adb shell am start -n ai.openclaw.app/.MainActivity", "android:test": "cd apps/android && ./gradlew :app:testDebugUnitTest", "android:test:integration": "OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_ANDROID_NODE=1 vitest run --config vitest.live.config.ts src/gateway/android-node.capabilities.live.test.ts", - "build": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && node scripts/copy-bundled-plugin-metadata.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", - "build:docker": "node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && node scripts/copy-bundled-plugin-metadata.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", + "build": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", + "build:docker": "node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json || true", - "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts", + "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", "check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-i18n-glossary && pnpm docs:check-links", diff --git a/scripts/copy-bundled-plugin-metadata.mjs b/scripts/copy-bundled-plugin-metadata.mjs index 40d8baa5299..a137872d421 100644 --- a/scripts/copy-bundled-plugin-metadata.mjs +++ b/scripts/copy-bundled-plugin-metadata.mjs @@ -1,11 +1,7 @@ -#!/usr/bin/env node - import fs from "node:fs"; import path from "node:path"; - -const repoRoot = process.cwd(); -const extensionsRoot = path.join(repoRoot, "extensions"); -const distExtensionsRoot = path.join(repoRoot, "dist", "extensions"); +import { pathToFileURL } from "node:url"; +import { removeFileIfExists, writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs"; function rewritePackageExtensions(entries) { if (!Array.isArray(entries)) { @@ -21,37 +17,66 @@ function rewritePackageExtensions(entries) { }); } -for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) { - if (!dirent.isDirectory()) { - continue; +export function copyBundledPluginMetadata(params = {}) { + const repoRoot = params.cwd ?? process.cwd(); + const extensionsRoot = path.join(repoRoot, "extensions"); + const distExtensionsRoot = path.join(repoRoot, "dist", "extensions"); + if (!fs.existsSync(extensionsRoot)) { + return; } - const pluginDir = path.join(extensionsRoot, dirent.name); - const manifestPath = path.join(pluginDir, "openclaw.plugin.json"); - if (!fs.existsSync(manifestPath)) { - continue; + const sourcePluginDirs = new Set(); + + for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) { + if (!dirent.isDirectory()) { + continue; + } + sourcePluginDirs.add(dirent.name); + + const pluginDir = path.join(extensionsRoot, dirent.name); + const manifestPath = path.join(pluginDir, "openclaw.plugin.json"); + const distPluginDir = path.join(distExtensionsRoot, dirent.name); + const distManifestPath = path.join(distPluginDir, "openclaw.plugin.json"); + const distPackageJsonPath = path.join(distPluginDir, "package.json"); + if (!fs.existsSync(manifestPath)) { + removeFileIfExists(distManifestPath); + removeFileIfExists(distPackageJsonPath); + continue; + } + + writeTextFileIfChanged(distManifestPath, fs.readFileSync(manifestPath, "utf8")); + + const packageJsonPath = path.join(pluginDir, "package.json"); + if (!fs.existsSync(packageJsonPath)) { + removeFileIfExists(distPackageJsonPath); + continue; + } + + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + if (packageJson.openclaw && "extensions" in packageJson.openclaw) { + packageJson.openclaw = { + ...packageJson.openclaw, + extensions: rewritePackageExtensions(packageJson.openclaw.extensions), + }; + } + + writeTextFileIfChanged(distPackageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); } - const distPluginDir = path.join(distExtensionsRoot, dirent.name); - fs.mkdirSync(distPluginDir, { recursive: true }); - fs.copyFileSync(manifestPath, path.join(distPluginDir, "openclaw.plugin.json")); - - const packageJsonPath = path.join(pluginDir, "package.json"); - if (!fs.existsSync(packageJsonPath)) { - continue; + if (!fs.existsSync(distExtensionsRoot)) { + return; } - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); - if (packageJson.openclaw && "extensions" in packageJson.openclaw) { - packageJson.openclaw = { - ...packageJson.openclaw, - extensions: rewritePackageExtensions(packageJson.openclaw.extensions), - }; + for (const dirent of fs.readdirSync(distExtensionsRoot, { withFileTypes: true })) { + if (!dirent.isDirectory() || sourcePluginDirs.has(dirent.name)) { + continue; + } + const distPluginDir = path.join(distExtensionsRoot, dirent.name); + removeFileIfExists(path.join(distPluginDir, "openclaw.plugin.json")); + removeFileIfExists(path.join(distPluginDir, "package.json")); } - - fs.writeFileSync( - path.join(distPluginDir, "package.json"), - `${JSON.stringify(packageJson, null, 2)}\n`, - "utf8", - ); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + copyBundledPluginMetadata(); } diff --git a/scripts/copy-plugin-sdk-root-alias.mjs b/scripts/copy-plugin-sdk-root-alias.mjs index b1bf80b6312..982a5fa9eeb 100644 --- a/scripts/copy-plugin-sdk-root-alias.mjs +++ b/scripts/copy-plugin-sdk-root-alias.mjs @@ -1,10 +1,16 @@ -#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs"; -import { copyFileSync, mkdirSync } from "node:fs"; -import { dirname, resolve } from "node:path"; +export function copyPluginSdkRootAlias(params = {}) { + const cwd = params.cwd ?? process.cwd(); + const source = resolve(cwd, "src/plugin-sdk/root-alias.cjs"); + const target = resolve(cwd, "dist/plugin-sdk/root-alias.cjs"); -const source = resolve("src/plugin-sdk/root-alias.cjs"); -const target = resolve("dist/plugin-sdk/root-alias.cjs"); + writeTextFileIfChanged(target, readFileSync(source, "utf8")); +} -mkdirSync(dirname(target), { recursive: true }); -copyFileSync(source, target); +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + copyPluginSdkRootAlias(); +} diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index 0e3acd763b9..56a63805e70 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -4,6 +4,7 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; +import { runRuntimePostBuild } from "./runtime-postbuild.mjs"; const compiler = "tsdown"; const compilerArgs = ["exec", compiler, "--no-clean"]; @@ -275,6 +276,19 @@ const runOpenClaw = async (deps) => { return res.exitCode ?? 1; }; +const syncRuntimeArtifacts = (deps) => { + try { + runRuntimePostBuild({ cwd: deps.cwd }); + } catch (error) { + logRunner( + `Failed to write runtime build artifacts: ${error?.message ?? "unknown error"}`, + deps, + ); + return false; + } + return true; +}; + const writeBuildStamp = (deps) => { try { deps.fs.mkdirSync(deps.distRoot, { recursive: true }); @@ -312,6 +326,9 @@ export async function runNodeMain(params = {}) { deps.configFiles = runNodeConfigFiles.map((filePath) => path.join(deps.cwd, filePath)); if (!shouldBuild(deps)) { + if (!syncRuntimeArtifacts(deps)) { + return 1; + } return await runOpenClaw(deps); } @@ -334,6 +351,9 @@ export async function runNodeMain(params = {}) { if (buildRes.exitCode !== 0 && buildRes.exitCode !== null) { return buildRes.exitCode; } + if (!syncRuntimeArtifacts(deps)) { + return 1; + } writeBuildStamp(deps); return await runOpenClaw(deps); } diff --git a/scripts/runtime-postbuild-shared.mjs b/scripts/runtime-postbuild-shared.mjs new file mode 100644 index 00000000000..34ca6bb7930 --- /dev/null +++ b/scripts/runtime-postbuild-shared.mjs @@ -0,0 +1,26 @@ +import fs from "node:fs"; +import { dirname } from "node:path"; + +export function writeTextFileIfChanged(filePath, contents) { + const next = String(contents); + try { + const current = fs.readFileSync(filePath, "utf8"); + if (current === next) { + return false; + } + } catch { + // Write the file when it does not exist or cannot be read. + } + fs.mkdirSync(dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, next, "utf8"); + return true; +} + +export function removeFileIfExists(filePath) { + try { + fs.rmSync(filePath, { force: true }); + return true; + } catch { + return false; + } +} diff --git a/scripts/runtime-postbuild.mjs b/scripts/runtime-postbuild.mjs new file mode 100644 index 00000000000..884ba7af036 --- /dev/null +++ b/scripts/runtime-postbuild.mjs @@ -0,0 +1,12 @@ +import { pathToFileURL } from "node:url"; +import { copyBundledPluginMetadata } from "./copy-bundled-plugin-metadata.mjs"; +import { copyPluginSdkRootAlias } from "./copy-plugin-sdk-root-alias.mjs"; + +export function runRuntimePostBuild(params = {}) { + copyPluginSdkRootAlias(params); + copyBundledPluginMetadata(params); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + runRuntimePostBuild(); +} diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index 7ba07fdaf2d..59ac7cd0666 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -24,6 +24,15 @@ function createExitedProcess(code: number | null, signal: string | null = null) }; } +async function writeRuntimePostBuildScaffold(tmp: string): Promise { + const pluginSdkAliasPath = path.join(tmp, "src", "plugin-sdk", "root-alias.cjs"); + await fs.mkdir(path.dirname(pluginSdkAliasPath), { recursive: true }); + await fs.mkdir(path.join(tmp, "extensions"), { recursive: true }); + await fs.writeFile(pluginSdkAliasPath, "module.exports = {};\n", "utf-8"); + const baselineTime = new Date("2026-03-13T09:00:00.000Z"); + await fs.utimes(pluginSdkAliasPath, baselineTime, baselineTime); +} + function expectedBuildSpawn(platform: NodeJS.Platform = process.platform) { return platform === "win32" ? ["cmd.exe", "/d", "/s", "/c", "pnpm", "exec", "tsdown", "--no-clean"] @@ -38,6 +47,7 @@ describe("run-node script", () => { const argsPath = path.join(tmp, ".pnpm-args.txt"); const indexPath = path.join(tmp, "dist", "control-ui", "index.html"); + await writeRuntimePostBuildScaffold(tmp); await fs.mkdir(path.dirname(indexPath), { recursive: true }); await fs.writeFile(indexPath, "sentinel\n", "utf-8"); @@ -84,6 +94,73 @@ describe("run-node script", () => { }, ); + it("copies bundled plugin metadata after rebuilding from a clean dist", async () => { + await withTempDir(async (tmp) => { + const extensionManifestPath = path.join(tmp, "extensions", "demo", "openclaw.plugin.json"); + const extensionPackagePath = path.join(tmp, "extensions", "demo", "package.json"); + + await writeRuntimePostBuildScaffold(tmp); + await fs.mkdir(path.dirname(extensionManifestPath), { recursive: true }); + await fs.writeFile( + extensionManifestPath, + '{"id":"demo","configSchema":{"type":"object"}}\n', + "utf-8", + ); + await fs.writeFile( + extensionPackagePath, + JSON.stringify( + { + name: "demo", + openclaw: { + extensions: ["./src/index.ts", "./nested/entry.mts"], + }, + }, + null, + 2, + ) + "\n", + "utf-8", + ); + + const spawnCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + spawnCalls.push([cmd, ...args]); + return createExitedProcess(0); + }; + + const { runNodeMain } = await import("../../scripts/run-node.mjs"); + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_FORCE_BUILD: "1", + OPENCLAW_RUNNER_LOG: "0", + }, + spawn, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([ + expectedBuildSpawn(), + [process.execPath, "openclaw.mjs", "status"], + ]); + + await expect( + fs.readFile(path.join(tmp, "dist", "plugin-sdk", "root-alias.cjs"), "utf-8"), + ).resolves.toContain("module.exports = {};"); + await expect( + fs.readFile(path.join(tmp, "dist", "extensions", "demo", "openclaw.plugin.json"), "utf-8"), + ).resolves.toContain('"id":"demo"'); + await expect( + fs.readFile(path.join(tmp, "dist", "extensions", "demo", "package.json"), "utf-8"), + ).resolves.toContain( + '"extensions": [\n "./src/index.js",\n "./nested/entry.js"\n ]', + ); + }); + }); + it("skips rebuilding when dist is current and the source tree is clean", async () => { await withTempDir(async (tmp) => { const srcPath = path.join(tmp, "src", "index.ts"); @@ -91,6 +168,7 @@ describe("run-node script", () => { const buildStampPath = path.join(tmp, "dist", ".buildstamp"); const tsconfigPath = path.join(tmp, "tsconfig.json"); const packageJsonPath = path.join(tmp, "package.json"); + await writeRuntimePostBuildScaffold(tmp); await fs.mkdir(path.dirname(srcPath), { recursive: true }); await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8"); @@ -175,6 +253,7 @@ describe("run-node script", () => { const buildStampPath = path.join(tmp, "dist", ".buildstamp"); const tsconfigPath = path.join(tmp, "tsconfig.json"); const packageJsonPath = path.join(tmp, "package.json"); + await writeRuntimePostBuildScaffold(tmp); await fs.mkdir(path.dirname(extensionPath), { recursive: true }); await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); await fs.writeFile(extensionPath, "export const extensionValue = 1;\n", "utf-8"); @@ -222,14 +301,20 @@ describe("run-node script", () => { it("skips rebuilding when extension package metadata is newer than the build stamp", async () => { await withTempDir(async (tmp) => { + const manifestPath = path.join(tmp, "extensions", "demo", "openclaw.plugin.json"); const packagePath = path.join(tmp, "extensions", "demo", "package.json"); + const distPackagePath = path.join(tmp, "dist", "extensions", "demo", "package.json"); const distEntryPath = path.join(tmp, "dist", "entry.js"); const buildStampPath = path.join(tmp, "dist", ".buildstamp"); const tsconfigPath = path.join(tmp, "tsconfig.json"); const packageJsonPath = path.join(tmp, "package.json"); const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await writeRuntimePostBuildScaffold(tmp); + await fs.mkdir(path.dirname(manifestPath), { recursive: true }); await fs.mkdir(path.dirname(packagePath), { recursive: true }); await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); + await fs.mkdir(path.dirname(distPackagePath), { recursive: true }); + await fs.writeFile(manifestPath, '{"id":"demo","configSchema":{"type":"object"}}\n', "utf-8"); await fs.writeFile( packagePath, '{"name":"demo","openclaw":{"extensions":["./index.ts"]}}\n', @@ -239,11 +324,17 @@ describe("run-node script", () => { await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); + await fs.writeFile( + distPackagePath, + '{"name":"demo","openclaw":{"extensions":["./stale.js"]}}\n', + "utf-8", + ); await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); const oldTime = new Date("2026-03-13T10:00:00.000Z"); const stampTime = new Date("2026-03-13T12:00:00.000Z"); const newTime = new Date("2026-03-13T12:00:01.000Z"); + await fs.utimes(manifestPath, oldTime, oldTime); await fs.utimes(tsconfigPath, oldTime, oldTime); await fs.utimes(packageJsonPath, oldTime, oldTime); await fs.utimes(tsdownConfigPath, oldTime, oldTime); @@ -274,6 +365,7 @@ describe("run-node script", () => { expect(exitCode).toBe(0); expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); + await expect(fs.readFile(distPackagePath, "utf-8")).resolves.toContain('"./index.js"'); }); }); @@ -286,6 +378,7 @@ describe("run-node script", () => { const tsconfigPath = path.join(tmp, "tsconfig.json"); const packageJsonPath = path.join(tmp, "package.json"); const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await writeRuntimePostBuildScaffold(tmp); await fs.mkdir(path.dirname(srcPath), { recursive: true }); await fs.mkdir(path.dirname(readmePath), { recursive: true }); await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); @@ -344,20 +437,28 @@ describe("run-node script", () => { await withTempDir(async (tmp) => { const srcPath = path.join(tmp, "src", "index.ts"); const manifestPath = path.join(tmp, "extensions", "demo", "openclaw.plugin.json"); + const distManifestPath = path.join(tmp, "dist", "extensions", "demo", "openclaw.plugin.json"); const distEntryPath = path.join(tmp, "dist", "entry.js"); const buildStampPath = path.join(tmp, "dist", ".buildstamp"); const tsconfigPath = path.join(tmp, "tsconfig.json"); const packageJsonPath = path.join(tmp, "package.json"); const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await writeRuntimePostBuildScaffold(tmp); await fs.mkdir(path.dirname(srcPath), { recursive: true }); await fs.mkdir(path.dirname(manifestPath), { recursive: true }); await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); + await fs.mkdir(path.dirname(distManifestPath), { recursive: true }); await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8"); - await fs.writeFile(manifestPath, '{"id":"demo"}\n', "utf-8"); + await fs.writeFile(manifestPath, '{"id":"demo","configSchema":{"type":"object"}}\n', "utf-8"); await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); + await fs.writeFile( + distManifestPath, + '{"id":"stale","configSchema":{"type":"object"}}\n', + "utf-8", + ); await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); const stampTime = new Date("2026-03-13T12:00:00.000Z"); @@ -400,6 +501,146 @@ describe("run-node script", () => { expect(exitCode).toBe(0); expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); + await expect(fs.readFile(distManifestPath, "utf-8")).resolves.toContain('"id":"demo"'); + }); + }); + + it("repairs missing bundled plugin metadata without rerunning tsdown", async () => { + await withTempDir(async (tmp) => { + const srcPath = path.join(tmp, "src", "index.ts"); + const manifestPath = path.join(tmp, "extensions", "demo", "openclaw.plugin.json"); + const distManifestPath = path.join(tmp, "dist", "extensions", "demo", "openclaw.plugin.json"); + const distEntryPath = path.join(tmp, "dist", "entry.js"); + const buildStampPath = path.join(tmp, "dist", ".buildstamp"); + const tsconfigPath = path.join(tmp, "tsconfig.json"); + const packageJsonPath = path.join(tmp, "package.json"); + const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await writeRuntimePostBuildScaffold(tmp); + await fs.mkdir(path.dirname(srcPath), { recursive: true }); + await fs.mkdir(path.dirname(manifestPath), { recursive: true }); + await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); + await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8"); + await fs.writeFile(manifestPath, '{"id":"demo","configSchema":{"type":"object"}}\n', "utf-8"); + await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); + await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); + await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); + await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); + await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); + + const stampTime = new Date("2026-03-13T12:00:00.000Z"); + await fs.utimes(srcPath, stampTime, stampTime); + await fs.utimes(manifestPath, stampTime, stampTime); + await fs.utimes(tsconfigPath, stampTime, stampTime); + await fs.utimes(packageJsonPath, stampTime, stampTime); + await fs.utimes(tsdownConfigPath, stampTime, stampTime); + await fs.utimes(distEntryPath, stampTime, stampTime); + await fs.utimes(buildStampPath, stampTime, stampTime); + + const spawnCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + spawnCalls.push([cmd, ...args]); + return createExitedProcess(0); + }; + const spawnSync = (cmd: string, args: string[]) => { + if (cmd === "git" && args[0] === "rev-parse") { + return { status: 0, stdout: "abc123\n" }; + } + if (cmd === "git" && args[0] === "status") { + return { status: 0, stdout: "" }; + } + return { status: 1, stdout: "" }; + }; + + const { runNodeMain } = await import("../../scripts/run-node.mjs"); + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + }, + spawn, + spawnSync, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); + await expect(fs.readFile(distManifestPath, "utf-8")).resolves.toContain('"id":"demo"'); + }); + }); + + it("removes stale bundled plugin metadata when the source manifest is gone", async () => { + await withTempDir(async (tmp) => { + const srcPath = path.join(tmp, "src", "index.ts"); + const extensionDir = path.join(tmp, "extensions", "demo"); + const distManifestPath = path.join(tmp, "dist", "extensions", "demo", "openclaw.plugin.json"); + const distPackagePath = path.join(tmp, "dist", "extensions", "demo", "package.json"); + const distEntryPath = path.join(tmp, "dist", "entry.js"); + const buildStampPath = path.join(tmp, "dist", ".buildstamp"); + const tsconfigPath = path.join(tmp, "tsconfig.json"); + const packageJsonPath = path.join(tmp, "package.json"); + const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await writeRuntimePostBuildScaffold(tmp); + await fs.mkdir(path.dirname(srcPath), { recursive: true }); + await fs.mkdir(extensionDir, { recursive: true }); + await fs.mkdir(path.dirname(distManifestPath), { recursive: true }); + await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); + await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8"); + await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); + await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); + await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); + await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); + await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); + await fs.writeFile( + distManifestPath, + '{"id":"stale","configSchema":{"type":"object"}}\n', + "utf-8", + ); + await fs.writeFile(distPackagePath, '{"name":"stale"}\n', "utf-8"); + + const stampTime = new Date("2026-03-13T12:00:00.000Z"); + await fs.utimes(srcPath, stampTime, stampTime); + await fs.utimes(tsconfigPath, stampTime, stampTime); + await fs.utimes(packageJsonPath, stampTime, stampTime); + await fs.utimes(tsdownConfigPath, stampTime, stampTime); + await fs.utimes(distEntryPath, stampTime, stampTime); + await fs.utimes(buildStampPath, stampTime, stampTime); + + const spawnCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + spawnCalls.push([cmd, ...args]); + return createExitedProcess(0); + }; + const spawnSync = (cmd: string, args: string[]) => { + if (cmd === "git" && args[0] === "rev-parse") { + return { status: 0, stdout: "abc123\n" }; + } + if (cmd === "git" && args[0] === "status") { + return { status: 0, stdout: "" }; + } + return { status: 1, stdout: "" }; + }; + + const { runNodeMain } = await import("../../scripts/run-node.mjs"); + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + }, + spawn, + spawnSync, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); + await expect(fs.access(distManifestPath)).rejects.toThrow(); + await expect(fs.access(distPackagePath)).rejects.toThrow(); }); }); @@ -412,6 +653,7 @@ describe("run-node script", () => { const tsconfigPath = path.join(tmp, "tsconfig.json"); const packageJsonPath = path.join(tmp, "package.json"); const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await writeRuntimePostBuildScaffold(tmp); await fs.mkdir(path.dirname(srcPath), { recursive: true }); await fs.mkdir(path.dirname(readmePath), { recursive: true }); await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); @@ -468,6 +710,7 @@ describe("run-node script", () => { const tsconfigPath = path.join(tmp, "tsconfig.json"); const packageJsonPath = path.join(tmp, "package.json"); const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await writeRuntimePostBuildScaffold(tmp); await fs.mkdir(path.dirname(srcPath), { recursive: true }); await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8");