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/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/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 index fd4a36d4a9f..a19d1861c7e 100644 --- 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 @@ -1,4 +1,2 @@ -export { - resolveProviderPluginChoice, -} from "../../../plugins/provider-wizard.js"; +export { resolveProviderPluginChoice } from "../../../plugins/provider-wizard.js"; export { resolvePluginProviders } from "../../../plugins/providers.js"; 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); }); });