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.
This commit is contained in:
parent
8a05c05596
commit
69ec65fc18
@ -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",
|
||||
|
||||
116
scripts/patch-esm-exports.cjs
Normal file
116
scripts/patch-esm-exports.cjs
Normal file
@ -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 };
|
||||
342
src/scripts/patch-esm-exports.test.ts
Normal file
342
src/scripts/patch-esm-exports.test.ts
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user