Merge branch 'main' into improve/edge-tts-followup

This commit is contained in:
Hiago Silva 2026-03-15 18:54:47 -03:00 committed by GitHub
commit 5e00e7645d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 338 additions and 57 deletions

View File

@ -1,7 +1,13 @@
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { removeFileIfExists, writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs";
import {
removeFileIfExists,
removePathIfExists,
writeTextFileIfChanged,
} from "./runtime-postbuild-shared.mjs";
const GENERATED_BUNDLED_SKILLS_DIR = "bundled-skills";
export function rewritePackageExtensions(entries) {
if (!Array.isArray(entries)) {
@ -30,6 +36,31 @@ function ensurePathInsideRoot(rootDir, rawPath) {
throw new Error(`path escapes plugin root: ${rawPath}`);
}
function normalizeManifestRelativePath(rawPath) {
return rawPath.replaceAll("\\", "/").replace(/^\.\//u, "");
}
function resolveBundledSkillTarget(rawPath) {
const normalized = normalizeManifestRelativePath(rawPath);
if (/^node_modules(?:\/|$)/u.test(normalized)) {
// Bundled dist/plugin roots must not publish nested node_modules trees. Relocate
// dependency-backed skill assets into a dist-owned directory and rewrite the manifest.
const trimmed = normalized.replace(/^node_modules\/?/u, "");
if (!trimmed) {
throw new Error(`node_modules skill path must point to a package: ${rawPath}`);
}
const bundledRelativePath = `${GENERATED_BUNDLED_SKILLS_DIR}/${trimmed}`;
return {
manifestPath: `./${bundledRelativePath}`,
outputPath: bundledRelativePath,
};
}
return {
manifestPath: rawPath,
outputPath: normalized,
};
}
function copyDeclaredPluginSkillPaths(params) {
const skills = Array.isArray(params.manifest.skills) ? params.manifest.skills : [];
const copiedSkills = [];
@ -37,8 +68,8 @@ function copyDeclaredPluginSkillPaths(params) {
if (typeof raw !== "string" || raw.trim().length === 0) {
continue;
}
const normalized = raw.replace(/^\.\//u, "");
const sourcePath = ensurePathInsideRoot(params.pluginDir, raw);
const target = resolveBundledSkillTarget(raw);
if (!fs.existsSync(sourcePath)) {
// Some Docker/lightweight builds intentionally omit optional plugin-local
// dependencies. Only advertise skill paths that were actually bundled.
@ -47,14 +78,25 @@ function copyDeclaredPluginSkillPaths(params) {
);
continue;
}
const targetPath = ensurePathInsideRoot(params.distPluginDir, normalized);
const targetPath = ensurePathInsideRoot(params.distPluginDir, target.outputPath);
removePathIfExists(targetPath);
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
const shouldExcludeNestedNodeModules = /^node_modules(?:\/|$)/u.test(
normalizeManifestRelativePath(raw),
);
fs.cpSync(sourcePath, targetPath, {
dereference: true,
force: true,
recursive: true,
filter: (candidatePath) => {
if (!shouldExcludeNestedNodeModules || candidatePath === sourcePath) {
return true;
}
const relativeCandidate = path.relative(sourcePath, candidatePath).replaceAll("\\", "/");
return !relativeCandidate.split("/").includes("node_modules");
},
});
copiedSkills.push(raw);
copiedSkills.push(target.manifestPath);
}
return copiedSkills;
}
@ -68,6 +110,12 @@ export function copyBundledPluginMetadata(params = {}) {
}
const sourcePluginDirs = new Set();
const removeGeneratedPluginArtifacts = (distPluginDir) => {
removeFileIfExists(path.join(distPluginDir, "openclaw.plugin.json"));
removeFileIfExists(path.join(distPluginDir, "package.json"));
removePathIfExists(path.join(distPluginDir, GENERATED_BUNDLED_SKILLS_DIR));
removePathIfExists(path.join(distPluginDir, "node_modules"));
};
for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) {
if (!dirent.isDirectory()) {
@ -81,12 +129,15 @@ export function copyBundledPluginMetadata(params = {}) {
const distManifestPath = path.join(distPluginDir, "openclaw.plugin.json");
const distPackageJsonPath = path.join(distPluginDir, "package.json");
if (!fs.existsSync(manifestPath)) {
removeFileIfExists(distManifestPath);
removeFileIfExists(distPackageJsonPath);
removeGeneratedPluginArtifacts(distPluginDir);
continue;
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
// Generated skill assets live under a dedicated dist-owned directory. Also
// remove the older bad node_modules tree so release packs cannot pick it up.
removePathIfExists(path.join(distPluginDir, GENERATED_BUNDLED_SKILLS_DIR));
removePathIfExists(path.join(distPluginDir, "node_modules"));
const copiedSkills = copyDeclaredPluginSkillPaths({ manifest, pluginDir, distPluginDir });
const bundledManifest = Array.isArray(manifest.skills)
? { ...manifest, skills: copiedSkills }
@ -119,8 +170,7 @@ export function copyBundledPluginMetadata(params = {}) {
continue;
}
const distPluginDir = path.join(distExtensionsRoot, dirent.name);
removeFileIfExists(path.join(distPluginDir, "openclaw.plugin.json"));
removeFileIfExists(path.join(distPluginDir, "package.json"));
removeGeneratedPluginArtifacts(distPluginDir);
}
}

View File

@ -6,8 +6,8 @@ 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"];
const buildScript = "scripts/tsdown-build.mjs";
const compilerArgs = [buildScript, "--no-clean"];
const runNodeSourceRoots = ["src", "extensions"];
const runNodeConfigFiles = ["tsconfig.json", "package.json", "tsdown.config.ts"];
@ -313,7 +313,6 @@ export async function runNodeMain(params = {}) {
cwd: params.cwd ?? process.cwd(),
args: params.args ?? process.argv.slice(2),
env: params.env ? { ...params.env } : { ...process.env },
platform: params.platform ?? process.platform,
};
deps.distRoot = path.join(deps.cwd, "dist");
@ -333,9 +332,8 @@ export async function runNodeMain(params = {}) {
}
logRunner("Building TypeScript (dist is stale).", deps);
const buildCmd = deps.platform === "win32" ? "cmd.exe" : "pnpm";
const buildArgs =
deps.platform === "win32" ? ["/d", "/s", "/c", "pnpm", ...compilerArgs] : compilerArgs;
const buildCmd = deps.execPath;
const buildArgs = compilerArgs;
const build = deps.spawn(buildCmd, buildArgs, {
cwd: deps.cwd,
env: deps.env,

View File

@ -24,3 +24,12 @@ export function removeFileIfExists(filePath) {
return false;
}
}
export function removePathIfExists(filePath) {
try {
fs.rmSync(filePath, { recursive: true, force: true });
return true;
} catch {
return false;
}
}

View File

@ -3,9 +3,10 @@
import { spawnSync } from "node:child_process";
const logLevel = process.env.OPENCLAW_BUILD_VERBOSE ? "info" : "warn";
const extraArgs = process.argv.slice(2);
const result = spawnSync(
"pnpm",
["exec", "tsdown", "--config-loader", "unrun", "--logLevel", logLevel],
["exec", "tsdown", "--config-loader", "unrun", "--logLevel", logLevel, ...extraArgs],
{
stdio: "inherit",
shell: process.platform === "win32",

View File

@ -0,0 +1,2 @@
export { resolveProviderPluginChoice } from "../../../plugins/provider-wizard.js";
export { resolvePluginProviders } from "../../../plugins/providers.js";

View File

@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
import { applyNonInteractivePluginProviderChoice } from "./auth-choice.plugin-providers.js";
const resolvePreferredProviderForAuthChoice = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("../../auth-choice.preferred-provider.js", () => ({
resolvePreferredProviderForAuthChoice,
}));
const resolveProviderPluginChoice = vi.hoisted(() => vi.fn());
const resolvePluginProviders = vi.hoisted(() => vi.fn(() => []));
vi.mock("./auth-choice.plugin-providers.runtime.js", () => ({
resolveProviderPluginChoice,
resolvePluginProviders,
PROVIDER_PLUGIN_CHOICE_PREFIX: "provider-plugin:",
}));
beforeEach(() => {
vi.clearAllMocks();
});
function createRuntime() {
return {
error: vi.fn(),
exit: vi.fn(),
};
}
describe("applyNonInteractivePluginProviderChoice", () => {
it("loads plugin providers for provider-plugin auth choices", async () => {
const runtime = createRuntime();
const runNonInteractive = vi.fn(async () => ({ plugins: { allow: ["vllm"] } }));
resolvePluginProviders.mockReturnValue([{ id: "vllm", pluginId: "vllm" }] as never);
resolveProviderPluginChoice.mockReturnValue({
provider: { id: "vllm", pluginId: "vllm", label: "vLLM" },
method: { runNonInteractive },
});
const result = await applyNonInteractivePluginProviderChoice({
nextConfig: { agents: { defaults: {} } } as OpenClawConfig,
authChoice: "provider-plugin:vllm:custom",
opts: {} as never,
runtime: runtime as never,
baseConfig: { agents: { defaults: {} } } as OpenClawConfig,
resolveApiKey: vi.fn(),
toApiKeyCredential: vi.fn(),
});
expect(resolvePluginProviders).toHaveBeenCalledOnce();
expect(resolveProviderPluginChoice).toHaveBeenCalledOnce();
expect(runNonInteractive).toHaveBeenCalledOnce();
expect(result).toEqual({ plugins: { allow: ["vllm"] } });
});
});

View File

@ -3,11 +3,6 @@ import type { ApiKeyCredential } from "../../../agents/auth-profiles/types.js";
import { resolveDefaultAgentWorkspaceDir } from "../../../agents/workspace.js";
import type { OpenClawConfig } from "../../../config/config.js";
import { enablePluginInConfig } from "../../../plugins/enable.js";
import {
PROVIDER_PLUGIN_CHOICE_PREFIX,
resolveProviderPluginChoice,
} from "../../../plugins/provider-wizard.js";
import { resolvePluginProviders } from "../../../plugins/providers.js";
import type {
ProviderNonInteractiveApiKeyCredentialParams,
ProviderResolveNonInteractiveApiKeyParams,
@ -16,6 +11,12 @@ import type { RuntimeEnv } from "../../../runtime.js";
import { resolvePreferredProviderForAuthChoice } from "../../auth-choice.preferred-provider.js";
import type { OnboardOptions } from "../../onboard-types.js";
const PROVIDER_PLUGIN_CHOICE_PREFIX = "provider-plugin:";
async function loadPluginProviderRuntime() {
return import("./auth-choice.plugin-providers.runtime.js");
}
function buildIsolatedProviderResolutionConfig(
cfg: OpenClawConfig,
providerId: string | undefined,
@ -73,6 +74,7 @@ export async function applyNonInteractivePluginProviderChoice(params: {
params.nextConfig,
preferredProviderId,
);
const { resolveProviderPluginChoice, resolvePluginProviders } = await loadPluginProviderRuntime();
const providerChoice = resolveProviderPluginChoice({
providers: resolvePluginProviders({
config: resolutionConfig,

View File

@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
import { applyNonInteractiveAuthChoice } from "./auth-choice.js";
const applySimpleNonInteractiveApiKeyChoice = vi.hoisted(() =>
vi.fn<() => Promise<OpenClawConfig | null | undefined>>(async () => undefined),
);
vi.mock("./auth-choice.api-key-providers.js", () => ({
applySimpleNonInteractiveApiKeyChoice,
}));
const applyNonInteractivePluginProviderChoice = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("./auth-choice.plugin-providers.js", () => ({
applyNonInteractivePluginProviderChoice,
}));
const resolveNonInteractiveApiKey = vi.hoisted(() => vi.fn());
vi.mock("../api-keys.js", () => ({
resolveNonInteractiveApiKey,
}));
beforeEach(() => {
vi.clearAllMocks();
});
function createRuntime() {
return {
error: vi.fn(),
exit: vi.fn(),
log: vi.fn(),
};
}
describe("applyNonInteractiveAuthChoice", () => {
it("resolves builtin API key auth before plugin provider resolution", async () => {
const runtime = createRuntime();
const nextConfig = { agents: { defaults: {} } } as OpenClawConfig;
const resolvedConfig = { auth: { profiles: { "openai:default": { mode: "api_key" } } } };
applySimpleNonInteractiveApiKeyChoice.mockResolvedValueOnce(resolvedConfig as never);
const result = await applyNonInteractiveAuthChoice({
nextConfig,
authChoice: "openai-api-key",
opts: {} as never,
runtime: runtime as never,
baseConfig: nextConfig,
});
expect(result).toBe(resolvedConfig);
expect(applySimpleNonInteractiveApiKeyChoice).toHaveBeenCalledOnce();
expect(applyNonInteractivePluginProviderChoice).not.toHaveBeenCalled();
});
});

View File

@ -161,24 +161,6 @@ export async function applyNonInteractiveAuthChoice(params: {
return null;
}
const pluginProviderChoice = await applyNonInteractivePluginProviderChoice({
nextConfig,
authChoice,
opts,
runtime,
baseConfig,
resolveApiKey: (input) =>
resolveApiKey({
...input,
cfg: baseConfig,
runtime,
}),
toApiKeyCredential,
});
if (pluginProviderChoice !== undefined) {
return pluginProviderChoice;
}
if (authChoice === "token") {
const providerRaw = opts.tokenProvider?.trim();
if (!providerRaw) {
@ -484,6 +466,24 @@ export async function applyNonInteractiveAuthChoice(params: {
}
}
const pluginProviderChoice = await applyNonInteractivePluginProviderChoice({
nextConfig,
authChoice,
opts,
runtime,
baseConfig,
resolveApiKey: (input) =>
resolveApiKey({
...input,
cfg: baseConfig,
runtime,
}),
toApiKeyCredential,
});
if (pluginProviderChoice !== undefined) {
return pluginProviderChoice;
}
if (
authChoice === "oauth" ||
authChoice === "chutes" ||

View File

@ -33,10 +33,8 @@ async function writeRuntimePostBuildScaffold(tmp: string): Promise<void> {
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"]
: ["pnpm", "exec", "tsdown", "--no-clean"];
function expectedBuildSpawn() {
return [process.execPath, "scripts/tsdown-build.mjs", "--no-clean"];
}
describe("run-node script", () => {
@ -44,7 +42,7 @@ describe("run-node script", () => {
"preserves control-ui assets by building with tsdown --no-clean",
async () => {
await withTempDir(async (tmp) => {
const argsPath = path.join(tmp, ".pnpm-args.txt");
const argsPath = path.join(tmp, ".build-args.txt");
const indexPath = path.join(tmp, "dist", "control-ui", "index.html");
await writeRuntimePostBuildScaffold(tmp);
@ -53,7 +51,7 @@ describe("run-node script", () => {
const nodeCalls: string[][] = [];
const spawn = (cmd: string, args: string[]) => {
if (cmd === "pnpm") {
if (cmd === process.execPath && args[0] === "scripts/tsdown-build.mjs") {
fsSync.writeFileSync(argsPath, args.join(" "), "utf-8");
if (!args.includes("--no-clean")) {
fsSync.rmSync(path.join(tmp, "dist", "control-ui"), { recursive: true, force: true });
@ -87,9 +85,14 @@ describe("run-node script", () => {
});
expect(exitCode).toBe(0);
await expect(fs.readFile(argsPath, "utf-8")).resolves.toContain("exec tsdown --no-clean");
await expect(fs.readFile(argsPath, "utf-8")).resolves.toContain(
"scripts/tsdown-build.mjs --no-clean",
);
await expect(fs.readFile(indexPath, "utf-8")).resolves.toContain("sentinel");
expect(nodeCalls).toEqual([[process.execPath, "openclaw.mjs", "--version"]]);
expect(nodeCalls).toEqual([
[process.execPath, "scripts/tsdown-build.mjs", "--no-clean"],
[process.execPath, "openclaw.mjs", "--version"],
]);
});
},
);
@ -151,8 +154,10 @@ describe("run-node script", () => {
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"');
fs
.readFile(path.join(tmp, "dist", "extensions", "demo", "openclaw.plugin.json"), "utf-8")
.then((raw) => JSON.parse(raw)),
).resolves.toMatchObject({ id: "demo" });
await expect(
fs.readFile(path.join(tmp, "dist", "extensions", "demo", "package.json"), "utf-8"),
).resolves.toContain(
@ -222,7 +227,7 @@ describe("run-node script", () => {
it("returns the build exit code when the compiler step fails", async () => {
await withTempDir(async (tmp) => {
const spawn = (cmd: string, args: string[] = []) => {
if (cmd === "pnpm" || (cmd === "cmd.exe" && args.includes("pnpm"))) {
if (cmd === process.execPath && args[0] === "scripts/tsdown-build.mjs") {
return createExitedProcess(23);
}
return createExitedProcess(0);
@ -501,7 +506,11 @@ 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"');
await expect(
fs.readFile(distManifestPath, "utf-8").then((raw) => JSON.parse(raw)),
).resolves.toMatchObject({
id: "demo",
});
});
});
@ -567,7 +576,11 @@ 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"');
await expect(
fs.readFile(distManifestPath, "utf-8").then((raw) => JSON.parse(raw)),
).resolves.toMatchObject({
id: "demo",
});
});
});

View File

@ -66,13 +66,20 @@ describe("copyBundledPluginMetadata", () => {
"utf8",
),
).toContain("ACP Router");
const bundledManifest = JSON.parse(
fs.readFileSync(
path.join(repoRoot, "dist", "extensions", "acpx", "openclaw.plugin.json"),
"utf8",
),
) as { skills?: string[] };
expect(bundledManifest.skills).toEqual(["./skills"]);
const packageJson = JSON.parse(
fs.readFileSync(path.join(repoRoot, "dist", "extensions", "acpx", "package.json"), "utf8"),
) as { openclaw?: { extensions?: string[] } };
expect(packageJson.openclaw?.extensions).toEqual(["./index.js"]);
});
it("dereferences node_modules-backed skill paths into the bundled dist tree", () => {
it("relocates node_modules-backed skill paths into bundled-skills and rewrites the manifest", () => {
const repoRoot = makeRepoRoot("openclaw-bundled-plugin-node-modules-");
const pluginDir = path.join(repoRoot, "extensions", "tlon");
const storeSkillDir = path.join(
@ -86,6 +93,12 @@ describe("copyBundledPluginMetadata", () => {
);
fs.mkdirSync(storeSkillDir, { recursive: true });
fs.writeFileSync(path.join(storeSkillDir, "SKILL.md"), "# Tlon Skill\n", "utf8");
fs.mkdirSync(path.join(storeSkillDir, "node_modules", ".bin"), { recursive: true });
fs.writeFileSync(
path.join(storeSkillDir, "node_modules", ".bin", "tlon"),
"#!/bin/sh\n",
"utf8",
);
fs.mkdirSync(path.join(pluginDir, "node_modules", "@tloncorp"), { recursive: true });
fs.symlinkSync(
storeSkillDir,
@ -101,10 +114,7 @@ describe("copyBundledPluginMetadata", () => {
name: "@openclaw/tlon",
openclaw: { extensions: ["./index.ts"] },
});
copyBundledPluginMetadata({ repoRoot });
const copiedSkillDir = path.join(
const staleNodeModulesSkillDir = path.join(
repoRoot,
"dist",
"extensions",
@ -113,11 +123,36 @@ describe("copyBundledPluginMetadata", () => {
"@tloncorp",
"tlon-skill",
);
fs.mkdirSync(staleNodeModulesSkillDir, { recursive: true });
fs.writeFileSync(path.join(staleNodeModulesSkillDir, "stale.txt"), "stale\n", "utf8");
copyBundledPluginMetadata({ repoRoot });
const copiedSkillDir = path.join(
repoRoot,
"dist",
"extensions",
"tlon",
"bundled-skills",
"@tloncorp",
"tlon-skill",
);
expect(fs.existsSync(path.join(copiedSkillDir, "SKILL.md"))).toBe(true);
expect(fs.lstatSync(copiedSkillDir).isSymbolicLink()).toBe(false);
expect(fs.existsSync(path.join(copiedSkillDir, "node_modules"))).toBe(false);
expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "tlon", "node_modules"))).toBe(
false,
);
const bundledManifest = JSON.parse(
fs.readFileSync(
path.join(repoRoot, "dist", "extensions", "tlon", "openclaw.plugin.json"),
"utf8",
),
) as { skills?: string[] };
expect(bundledManifest.skills).toEqual(["./bundled-skills/@tloncorp/tlon-skill"]);
});
it("omits missing declared skill paths from the bundled manifest", () => {
it("omits missing declared skill paths and removes stale generated outputs", () => {
const repoRoot = makeRepoRoot("openclaw-bundled-plugin-missing-skill-");
const pluginDir = path.join(repoRoot, "extensions", "tlon");
fs.mkdirSync(pluginDir, { recursive: true });
@ -130,6 +165,19 @@ describe("copyBundledPluginMetadata", () => {
name: "@openclaw/tlon",
openclaw: { extensions: ["./index.ts"] },
});
const staleBundledSkillDir = path.join(
repoRoot,
"dist",
"extensions",
"tlon",
"bundled-skills",
"@tloncorp",
"tlon-skill",
);
fs.mkdirSync(staleBundledSkillDir, { recursive: true });
fs.writeFileSync(path.join(staleBundledSkillDir, "SKILL.md"), "# stale\n", "utf8");
const staleNodeModulesDir = path.join(repoRoot, "dist", "extensions", "tlon", "node_modules");
fs.mkdirSync(staleNodeModulesDir, { recursive: true });
copyBundledPluginMetadata({ repoRoot });
@ -140,5 +188,56 @@ describe("copyBundledPluginMetadata", () => {
),
) as { skills?: string[] };
expect(bundledManifest.skills).toEqual([]);
expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "tlon", "bundled-skills"))).toBe(
false,
);
expect(fs.existsSync(staleNodeModulesDir)).toBe(false);
});
it("removes generated outputs for plugins no longer present in source", () => {
const repoRoot = makeRepoRoot("openclaw-bundled-plugin-removed-");
const staleBundledSkillDir = path.join(
repoRoot,
"dist",
"extensions",
"removed-plugin",
"bundled-skills",
"@scope",
"skill",
);
fs.mkdirSync(staleBundledSkillDir, { recursive: true });
fs.writeFileSync(path.join(staleBundledSkillDir, "SKILL.md"), "# stale\n", "utf8");
const staleNodeModulesDir = path.join(
repoRoot,
"dist",
"extensions",
"removed-plugin",
"node_modules",
);
fs.mkdirSync(staleNodeModulesDir, { recursive: true });
writeJson(path.join(repoRoot, "dist", "extensions", "removed-plugin", "openclaw.plugin.json"), {
id: "removed-plugin",
configSchema: { type: "object" },
skills: ["./bundled-skills/@scope/skill"],
});
writeJson(path.join(repoRoot, "dist", "extensions", "removed-plugin", "package.json"), {
name: "@openclaw/removed-plugin",
});
fs.mkdirSync(path.join(repoRoot, "extensions"), { recursive: true });
copyBundledPluginMetadata({ repoRoot });
expect(
fs.existsSync(
path.join(repoRoot, "dist", "extensions", "removed-plugin", "openclaw.plugin.json"),
),
).toBe(false);
expect(
fs.existsSync(path.join(repoRoot, "dist", "extensions", "removed-plugin", "package.json")),
).toBe(false);
expect(
fs.existsSync(path.join(repoRoot, "dist", "extensions", "removed-plugin", "bundled-skills")),
).toBe(false);
expect(fs.existsSync(staleNodeModulesDir)).toBe(false);
});
});