diff --git a/scripts/stage-bundled-plugin-runtime.mjs b/scripts/stage-bundled-plugin-runtime.mjs index f38f52aa6c5..303ce164714 100644 --- a/scripts/stage-bundled-plugin-runtime.mjs +++ b/scripts/stage-bundled-plugin-runtime.mjs @@ -12,8 +12,30 @@ function relativeSymlinkTarget(sourcePath, targetPath) { return relativeTarget || "."; } +function ensureSymlink(targetValue, targetPath, type) { + try { + fs.symlinkSync(targetValue, targetPath, type); + return; + } catch (error) { + if (error?.code !== "EEXIST") { + throw error; + } + } + + try { + if (fs.lstatSync(targetPath).isSymbolicLink() && fs.readlinkSync(targetPath) === targetValue) { + return; + } + } catch { + // Fall through and recreate the target when inspection fails. + } + + removePathIfExists(targetPath); + fs.symlinkSync(targetValue, targetPath, type); +} + function symlinkPath(sourcePath, targetPath, type) { - fs.symlinkSync(relativeSymlinkTarget(sourcePath, targetPath), targetPath, type); + ensureSymlink(relativeSymlinkTarget(sourcePath, targetPath), targetPath, type); } function shouldWrapRuntimeJsFile(sourcePath) { @@ -63,7 +85,7 @@ function stagePluginRuntimeOverlay(sourceDir, targetDir) { } if (dirent.isSymbolicLink()) { - fs.symlinkSync(fs.readlinkSync(sourcePath), targetPath); + ensureSymlink(fs.readlinkSync(sourcePath), targetPath); continue; } @@ -91,7 +113,7 @@ function linkPluginNodeModules(params) { if (!fs.existsSync(params.sourcePluginNodeModulesDir)) { return; } - fs.symlinkSync(params.sourcePluginNodeModulesDir, runtimeNodeModulesDir, symlinkType()); + ensureSymlink(params.sourcePluginNodeModulesDir, runtimeNodeModulesDir, symlinkType()); } export function stageBundledPluginRuntime(params = {}) { diff --git a/src/plugins/stage-bundled-plugin-runtime.test.ts b/src/plugins/stage-bundled-plugin-runtime.test.ts index 7bdb986e030..a0cd5db4dd7 100644 --- a/src/plugins/stage-bundled-plugin-runtime.test.ts +++ b/src/plugins/stage-bundled-plugin-runtime.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { stageBundledPluginRuntime } from "../../scripts/stage-bundled-plugin-runtime.mjs"; import { discoverOpenClawPlugins } from "./discovery.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; @@ -329,4 +329,40 @@ describe("stageBundledPluginRuntime", () => { expect(fs.existsSync(path.join(repoRoot, "dist-runtime"))).toBe(false); }); + + it("tolerates EEXIST when an identical runtime symlink is materialized concurrently", () => { + const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-eexist-"); + const distPluginDir = path.join(repoRoot, "dist", "extensions", "feishu"); + const distSkillDir = path.join(distPluginDir, "skills", "feishu-doc"); + fs.mkdirSync(distSkillDir, { recursive: true }); + fs.writeFileSync(path.join(distPluginDir, "index.js"), "export default {}\n", "utf8"); + fs.writeFileSync(path.join(distSkillDir, "SKILL.md"), "# Feishu Doc\n", "utf8"); + + const realSymlinkSync = fs.symlinkSync.bind(fs); + const symlinkSpy = vi.spyOn(fs, "symlinkSync").mockImplementation(((target, link, type) => { + const linkPath = String(link); + if (linkPath.endsWith(path.join("skills", "feishu-doc", "SKILL.md"))) { + const err = Object.assign(new Error("file already exists"), { code: "EEXIST" }); + realSymlinkSync(String(target), linkPath, type); + throw err; + } + return realSymlinkSync(String(target), linkPath, type); + }) as typeof fs.symlinkSync); + + expect(() => stageBundledPluginRuntime({ repoRoot })).not.toThrow(); + + const runtimeSkillPath = path.join( + repoRoot, + "dist-runtime", + "extensions", + "feishu", + "skills", + "feishu-doc", + "SKILL.md", + ); + expect(fs.lstatSync(runtimeSkillPath).isSymbolicLink()).toBe(true); + expect(fs.readFileSync(runtimeSkillPath, "utf8")).toBe("# Feishu Doc\n"); + + symlinkSpy.mockRestore(); + }); });