Gateway: sync runtime post-build artifacts

This commit is contained in:
Gustavo Madeira Santana
2026-03-15 20:44:03 +00:00
parent b795ba1d02
commit 4fb0160309
8 changed files with 376 additions and 43 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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();
}

View File

@@ -24,6 +24,15 @@ function createExitedProcess(code: number | null, signal: string | null = null)
};
}
async function writeRuntimePostBuildScaffold(tmp: string): Promise<void> {
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, "<html>sentinel</html>\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");