diff --git a/scripts/copy-bundled-plugin-metadata.mjs b/scripts/copy-bundled-plugin-metadata.mjs index af8612a3465..2ba04d9cda0 100644 --- a/scripts/copy-bundled-plugin-metadata.mjs +++ b/scripts/copy-bundled-plugin-metadata.mjs @@ -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); } } diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index 56a63805e70..33317ae8797 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -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, diff --git a/scripts/runtime-postbuild-shared.mjs b/scripts/runtime-postbuild-shared.mjs index 34ca6bb7930..7d60be6f746 100644 --- a/scripts/runtime-postbuild-shared.mjs +++ b/scripts/runtime-postbuild-shared.mjs @@ -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; + } +} diff --git a/scripts/tsdown-build.mjs b/scripts/tsdown-build.mjs index ccd56a4aff0..1c346b54a78 100644 --- a/scripts/tsdown-build.mjs +++ b/scripts/tsdown-build.mjs @@ -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", diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts new file mode 100644 index 00000000000..a19d1861c7e --- /dev/null +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts @@ -0,0 +1,2 @@ +export { resolveProviderPluginChoice } from "../../../plugins/provider-wizard.js"; +export { resolvePluginProviders } from "../../../plugins/providers.js"; diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts new file mode 100644 index 00000000000..4e0f37e2882 --- /dev/null +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts @@ -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"] } }); + }); +}); diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts index d6e1440eb20..e5c8dedb12f 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts @@ -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, diff --git a/src/commands/onboard-non-interactive/local/auth-choice.test.ts b/src/commands/onboard-non-interactive/local/auth-choice.test.ts new file mode 100644 index 00000000000..9fe7a34cda9 --- /dev/null +++ b/src/commands/onboard-non-interactive/local/auth-choice.test.ts @@ -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>(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(); + }); +}); diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 500e19ee574..6d360487ee9 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -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" || diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index 59ac7cd0666..dfebf6c2ad2 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -33,10 +33,8 @@ async function writeRuntimePostBuildScaffold(tmp: string): Promise { 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", + }); }); }); diff --git a/src/plugins/copy-bundled-plugin-metadata.test.ts b/src/plugins/copy-bundled-plugin-metadata.test.ts index 46036dc45d9..9c980381aa8 100644 --- a/src/plugins/copy-bundled-plugin-metadata.test.ts +++ b/src/plugins/copy-bundled-plugin-metadata.test.ts @@ -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); }); });