Plugins: relocate bundled skill assets

This commit is contained in:
Gustavo Madeira Santana
2026-03-15 21:41:41 +00:00
parent b810e94a17
commit 1839bc0b1a
3 changed files with 100 additions and 10 deletions

View File

@@ -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,15 @@ 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 });
fs.cpSync(sourcePath, targetPath, {
dereference: true,
force: true,
recursive: true,
});
copiedSkills.push(raw);
copiedSkills.push(target.manifestPath);
}
return copiedSkills;
}
@@ -87,6 +119,10 @@ export function copyBundledPluginMetadata(params = {}) {
}
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 }

View File

@@ -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;
}
}

View File

@@ -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(
@@ -101,10 +108,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 +117,35 @@ 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(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 +158,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 +181,9 @@ 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);
});
});