From 69ec65fc18124758f81822562f826c70123b388d Mon Sep 17 00:00:00 2001 From: Alberto Leal Date: Sat, 14 Feb 2026 00:25:27 -0500 Subject: [PATCH] fix(plugins): add postinstall patch for ESM-only package exports jiti (the TS/ESM loader used for plugin loading) converts imports to CJS require() internally. Three dependencies (@buape/carbon, osc-progress, @mariozechner/pi-coding-agent) ship export maps with only an "import" condition and no "default" or "require" fallback, causing ERR_PACKAGE_PATH_NOT_EXPORTED at runtime. This silently breaks all plugin loading for any plugin importing from openclaw/plugin-sdk. Add a postinstall script that walks node_modules and adds the missing "default" export condition to any package whose exports have "import" but neither "default" nor "require". The patch is idempotent, has zero runtime cost, and becomes a no-op if upstream packages add CJS support. --- package.json | 1 + scripts/patch-esm-exports.cjs | 116 +++++++++ src/scripts/patch-esm-exports.test.ts | 342 ++++++++++++++++++++++++++ 3 files changed, 459 insertions(+) create mode 100644 scripts/patch-esm-exports.cjs create mode 100644 src/scripts/patch-esm-exports.test.ts diff --git a/package.json b/package.json index 99529029aed..54e33086e64 100644 --- a/package.json +++ b/package.json @@ -529,6 +529,7 @@ "plugin-sdk:check-exports": "node scripts/sync-plugin-sdk-exports.mjs --check", "plugin-sdk:sync-exports": "node scripts/sync-plugin-sdk-exports.mjs", "plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts", + "postinstall": "node scripts/patch-esm-exports.cjs", "prepack": "pnpm build && pnpm ui:build", "prepare": "command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1 && git config core.hooksPath git-hooks || exit 0", "protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/OpenClawProtocol/GatewayModels.swift apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift", diff --git a/scripts/patch-esm-exports.cjs b/scripts/patch-esm-exports.cjs new file mode 100644 index 00000000000..50caee0f0ba --- /dev/null +++ b/scripts/patch-esm-exports.cjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +/** + * Postinstall patch: add "default" export condition to ESM-only packages. + * + * jiti (the TS/ESM loader used at runtime) converts imports to CJS require(). + * Some dependencies ship export maps with only an "import" condition and no + * "default" or "require" fallback, which causes ERR_PACKAGE_PATH_NOT_EXPORTED. + * This script walks node_modules and adds the missing "default" condition so + * both ESM and CJS resolution work. + * + * Safe to run multiple times (idempotent). Never exits non-zero. + */ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); + +const MAX_DEPTH = 8; +const SKIP_DIRS = new Set([".cache", ".store"]); + +/** + * Mutate an exports object in-place, adding a "default" condition to any entry + * that has "import" but neither "default" nor "require". + * + * @param {unknown} exports - The "exports" field from package.json + * @returns {boolean} Whether any entry was modified + */ +function patchExports(exports) { + if (typeof exports !== "object" || exports === null || Array.isArray(exports)) { + return false; + } + let modified = false; + for (const key of Object.keys(exports)) { + const entry = exports[key]; + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) { + continue; + } + if ("import" in entry && !("default" in entry) && !("require" in entry)) { + entry.default = entry.import; + modified = true; + } + } + return modified; +} + +/** + * Walk a directory tree and patch every package.json whose exports need a + * "default" condition. + * + * @param {string} dir - Root directory to walk (typically node_modules) + * @returns {{ patchedCount: number, errors: Array<{ file: string, error: string }> }} + */ +function patchDir(dir) { + let patchedCount = 0; + const errors = []; + + function walk(currentDir, depth) { + if (depth > MAX_DEPTH) { + return; + } + let entries; + try { + entries = fs.readdirSync(currentDir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const name = entry.name; + if (SKIP_DIRS.has(name)) { + continue; + } + const fullPath = path.join(currentDir, name); + + if (name === "package.json") { + try { + const content = fs.readFileSync(fullPath, "utf8"); + const pkg = JSON.parse(content); + if (pkg.exports && patchExports(pkg.exports)) { + fs.writeFileSync(fullPath, JSON.stringify(pkg, null, 2) + "\n"); + patchedCount++; + } + } catch (err) { + errors.push({ file: fullPath, error: err.message }); + } + continue; + } + + let isDir = entry.isDirectory(); + if (!isDir && entry.isSymbolicLink()) { + try { + isDir = fs.statSync(fullPath).isDirectory(); + } catch { + continue; + } + } + if (isDir) { + walk(fullPath, depth + 1); + } + } + } + + walk(dir, 0); + return { patchedCount, errors }; +} + +if (require.main === module) { + try { + const nodeModules = path.resolve(__dirname, "..", "node_modules"); + const { patchedCount } = patchDir(nodeModules); + console.log(`patch-esm-exports: patched ${patchedCount} package(s)`); + } catch (err) { + console.warn("patch-esm-exports: unexpected error —", err.message); + } +} + +module.exports = { patchExports, patchDir }; diff --git a/src/scripts/patch-esm-exports.test.ts b/src/scripts/patch-esm-exports.test.ts new file mode 100644 index 00000000000..bb057b96d59 --- /dev/null +++ b/src/scripts/patch-esm-exports.test.ts @@ -0,0 +1,342 @@ +import fs from "node:fs"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; + +const esmRequire = createRequire(import.meta.url); +const { patchExports, patchDir } = esmRequire("../../scripts/patch-esm-exports.cjs") as { + patchExports: (exports: unknown) => boolean; + patchDir: (dir: string) => { + patchedCount: number; + errors: Array<{ file: string; error: string }>; + }; +}; + +const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "esm-patch-test-")); + +afterAll(() => { + try { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } catch { + // ignore cleanup failures + } +}); + +let caseIndex = 0; +function makeDir() { + const dir = path.join(fixtureRoot, `case-${caseIndex++}`); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +function writePackageJson(dir: string, pkg: unknown) { + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify(pkg, null, 2) + "\n", "utf8"); +} + +function readPackageJson(dir: string) { + return JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")); +} + +describe("patchExports", () => { + it("adds 'default' condition when only 'import' exists", () => { + const exports = { + ".": { import: "./dist/index.mjs" }, + }; + const modified = patchExports(exports); + expect(modified).toBe(true); + expect(exports["."]).toEqual({ + import: "./dist/index.mjs", + default: "./dist/index.mjs", + }); + }); + + it("does not modify entries with existing 'default' condition", () => { + const exports = { + ".": { import: "./dist/index.mjs", default: "./dist/index.cjs" }, + }; + const modified = patchExports(exports); + expect(modified).toBe(false); + expect(exports["."]).toEqual({ + import: "./dist/index.mjs", + default: "./dist/index.cjs", + }); + }); + + it("does not modify entries with existing 'require' condition", () => { + const exports = { + ".": { import: "./dist/index.mjs", require: "./dist/index.cjs" }, + }; + const modified = patchExports(exports); + expect(modified).toBe(false); + expect(exports["."]).toEqual({ + import: "./dist/index.mjs", + require: "./dist/index.cjs", + }); + }); + + it("handles string shorthand exports without modification", () => { + const exports = { ".": "./dist/index.js" }; + const modified = patchExports(exports); + expect(modified).toBe(false); + }); + + it("handles non-object export values without modification", () => { + const exports = { ".": null, "./foo": 42 }; + const modified = patchExports(exports); + expect(modified).toBe(false); + }); + + it("handles array export values without modification", () => { + const exports = { ".": ["./dist/a.js", "./dist/b.js"] }; + const modified = patchExports(exports); + expect(modified).toBe(false); + }); + + it("returns false for null", () => { + expect(patchExports(null)).toBe(false); + }); + + it("returns false for a string", () => { + expect(patchExports("./index.js")).toBe(false); + }); + + it("returns false for an array", () => { + expect(patchExports(["./index.js"])).toBe(false); + }); + + it("handles multiple export entries", () => { + const exports = { + ".": { import: "./dist/index.mjs" }, + "./hooks": { import: "./dist/hooks.mjs" }, + "./*": { import: "./dist/*.mjs", require: "./dist/*.cjs" }, + }; + const modified = patchExports(exports); + expect(modified).toBe(true); + expect(exports["."]).toHaveProperty("default", "./dist/index.mjs"); + expect(exports["./hooks"]).toHaveProperty("default", "./dist/hooks.mjs"); + expect(exports["./*"]).not.toHaveProperty("default"); + }); + + it("preserves existing fields in the exports entry", () => { + const exports = { + ".": { import: "./dist/index.mjs", types: "./dist/index.d.ts" }, + }; + patchExports(exports); + expect(exports["."]).toEqual({ + import: "./dist/index.mjs", + types: "./dist/index.d.ts", + default: "./dist/index.mjs", + }); + }); +}); + +describe("patchDir", () => { + it("patches package.json that needs 'default' condition", () => { + const root = makeDir(); + const pkgDir = path.join(root, "node_modules", "esm-only-pkg"); + fs.mkdirSync(pkgDir, { recursive: true }); + writePackageJson(pkgDir, { + name: "esm-only-pkg", + exports: { ".": { import: "./dist/index.mjs" } }, + }); + + const result = patchDir(root); + + expect(result.patchedCount).toBe(1); + expect(result.errors).toHaveLength(0); + const pkg = readPackageJson(pkgDir); + expect(pkg.exports["."]).toHaveProperty("default", "./dist/index.mjs"); + }); + + it("does not modify packages with existing 'default' condition", () => { + const root = makeDir(); + const pkgDir = path.join(root, "node_modules", "dual-pkg"); + fs.mkdirSync(pkgDir, { recursive: true }); + writePackageJson(pkgDir, { + name: "dual-pkg", + exports: { ".": { import: "./dist/index.mjs", default: "./dist/index.cjs" } }, + }); + + const result = patchDir(root); + + expect(result.patchedCount).toBe(0); + const pkg = readPackageJson(pkgDir); + expect(pkg.exports["."]).toEqual({ + import: "./dist/index.mjs", + default: "./dist/index.cjs", + }); + }); + + it("handles packages with no exports field", () => { + const root = makeDir(); + const pkgDir = path.join(root, "node_modules", "no-exports-pkg"); + fs.mkdirSync(pkgDir, { recursive: true }); + writePackageJson(pkgDir, { name: "no-exports-pkg", main: "./index.js" }); + + const result = patchDir(root); + + expect(result.patchedCount).toBe(0); + expect(result.errors).toHaveLength(0); + }); + + it("handles malformed package.json gracefully", () => { + const root = makeDir(); + const pkgDir = path.join(root, "node_modules", "bad-pkg"); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync(path.join(pkgDir, "package.json"), "{ not valid json !!!", "utf8"); + + const result = patchDir(root); + + expect(result.patchedCount).toBe(0); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors[0].file).toContain("bad-pkg"); + }); + + it("is idempotent - running twice produces same result", () => { + const root = makeDir(); + const pkgDir = path.join(root, "node_modules", "idem-pkg"); + fs.mkdirSync(pkgDir, { recursive: true }); + writePackageJson(pkgDir, { + name: "idem-pkg", + exports: { ".": { import: "./dist/index.mjs" } }, + }); + + patchDir(root); + const afterFirst = readPackageJson(pkgDir); + + const secondResult = patchDir(root); + const afterSecond = readPackageJson(pkgDir); + + expect(secondResult.patchedCount).toBe(0); + expect(afterSecond).toEqual(afterFirst); + }); + + it("respects max depth limit", () => { + const root = makeDir(); + // Create a deeply nested directory (depth > 8) + let nested = root; + for (let i = 0; i < 10; i++) { + nested = path.join(nested, `level-${i}`); + } + fs.mkdirSync(nested, { recursive: true }); + writePackageJson(nested, { + name: "deep-pkg", + exports: { ".": { import: "./dist/index.mjs" } }, + }); + + const result = patchDir(root); + + expect(result.patchedCount).toBe(0); + }); + + it("skips .cache and .store directories", () => { + const root = makeDir(); + + for (const skipDir of [".cache", ".store"]) { + const pkgDir = path.join(root, skipDir, "hidden-pkg"); + fs.mkdirSync(pkgDir, { recursive: true }); + writePackageJson(pkgDir, { + name: `hidden-${skipDir}`, + exports: { ".": { import: "./dist/index.mjs" } }, + }); + } + + const result = patchDir(root); + + expect(result.patchedCount).toBe(0); + }); + + it("handles multiple packages in the same tree", () => { + const root = makeDir(); + const nm = path.join(root, "node_modules"); + + const pkgA = path.join(nm, "pkg-a"); + fs.mkdirSync(pkgA, { recursive: true }); + writePackageJson(pkgA, { + name: "pkg-a", + exports: { ".": { import: "./a.mjs" } }, + }); + + const pkgB = path.join(nm, "pkg-b"); + fs.mkdirSync(pkgB, { recursive: true }); + writePackageJson(pkgB, { + name: "pkg-b", + exports: { ".": { import: "./b.mjs", default: "./b.cjs" } }, + }); + + const pkgC = path.join(nm, "pkg-c"); + fs.mkdirSync(pkgC, { recursive: true }); + writePackageJson(pkgC, { + name: "pkg-c", + exports: { ".": { import: "./c.mjs" }, "./sub": { import: "./sub.mjs" } }, + }); + + const result = patchDir(root); + + expect(result.patchedCount).toBe(2); + expect(readPackageJson(pkgA).exports["."]).toHaveProperty("default", "./a.mjs"); + expect(readPackageJson(pkgB).exports["."].default).toBe("./b.cjs"); + expect(readPackageJson(pkgC).exports["."]).toHaveProperty("default", "./c.mjs"); + expect(readPackageJson(pkgC).exports["./sub"]).toHaveProperty("default", "./sub.mjs"); + }); +}); + +describe("affected packages verification", () => { + it("correctly identifies @buape/carbon as needing patch", () => { + const root = makeDir(); + const pkgDir = path.join(root, "node_modules", "@buape", "carbon"); + fs.mkdirSync(pkgDir, { recursive: true }); + writePackageJson(pkgDir, { + name: "@buape/carbon", + exports: { + ".": { import: "./dist/index.js", types: "./dist/index.d.ts" }, + "./*": { import: "./dist/*.js", types: "./dist/*.d.ts" }, + }, + }); + + const result = patchDir(root); + + expect(result.patchedCount).toBe(1); + const pkg = readPackageJson(pkgDir); + expect(pkg.exports["."]).toHaveProperty("default", "./dist/index.js"); + expect(pkg.exports["./*"]).toHaveProperty("default", "./dist/*.js"); + expect(pkg.exports["."].types).toBe("./dist/index.d.ts"); + }); + + it("correctly identifies osc-progress as needing patch", () => { + const root = makeDir(); + const pkgDir = path.join(root, "node_modules", "osc-progress"); + fs.mkdirSync(pkgDir, { recursive: true }); + writePackageJson(pkgDir, { + name: "osc-progress", + exports: { + ".": { import: "./dist/index.js", types: "./dist/index.d.ts" }, + }, + }); + + const result = patchDir(root); + + expect(result.patchedCount).toBe(1); + const pkg = readPackageJson(pkgDir); + expect(pkg.exports["."]).toHaveProperty("default", "./dist/index.js"); + }); + + it("correctly identifies @mariozechner/pi-coding-agent as needing patch", () => { + const root = makeDir(); + const pkgDir = path.join(root, "node_modules", "@mariozechner", "pi-coding-agent"); + fs.mkdirSync(pkgDir, { recursive: true }); + writePackageJson(pkgDir, { + name: "@mariozechner/pi-coding-agent", + exports: { + ".": { import: "./dist/index.js", types: "./dist/index.d.ts" }, + }, + }); + + const result = patchDir(root); + + expect(result.patchedCount).toBe(1); + const pkg = readPackageJson(pkgDir); + expect(pkg.exports["."]).toHaveProperty("default", "./dist/index.js"); + }); +});