FN-7060: sanitize bundled plugin manifests
Sanitize published CLI plugin manifests so off-workspace installs do not resolve private workspace packages. - Add manifest sanitization for copied bundled plugins and vendored pi extensions during the CLI build. - Cover built plugin and extension package.json files with a pack-shape regression test. - Update plugin authoring docs and add a patch changeset for the published CLI fix. Files changed: .../fn-7060-fix-plugin-manifest-workspace-deps.md | 7 ++ docs/PLUGIN_AUTHORING.md | 8 ++- .../cli/src/__tests__/plugin-pack-shape.test.ts | 55 +++++++++++++- packages/cli/tsup.config.ts | 83 ++++++++++++++++++++-- 4 files changed, 142 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-7060 Fusion-Task-Lineage: e38a4237-7197-4bc4-87bd-117dfd35a0f8
This commit is contained in:
7
.changeset/fn-7060-fix-plugin-manifest-workspace-deps.md
Normal file
7
.changeset/fn-7060-fix-plugin-manifest-workspace-deps.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix npm install failure caused by bundled plugins referencing private @fusion packages.
|
||||
category: fix
|
||||
dev: Sanitizes copied plugin and vendored extension manifests in tsup.config.ts before publishing.
|
||||
@@ -1194,6 +1194,8 @@ For end-to-end standalone packaging, `pnpm pack`, and installing on another mach
|
||||
|
||||
### Package Requirements
|
||||
|
||||
<!-- FNXC:Packaging 2026-06-26-08:55: Published plugin packages must not declare private @fusion/* or workspace:* dependencies; those names only resolve inside the monorepo and cause off-workspace package-manager installs to fail. -->
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "fusion-plugin-my-plugin",
|
||||
@@ -1201,12 +1203,12 @@ For end-to-end standalone packaging, `pnpm pack`, and installing on another mach
|
||||
"keywords": ["fusion-plugin"],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fusion/core": "workspace:*"
|
||||
"dependencies": {
|
||||
"@runfusion/fusion": "^0.48.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { extname, join } from "node:path";
|
||||
import { dirname, extname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadManifestFromPath, resolvePluginEntryFile } from "../commands/plugin.js";
|
||||
import { ALL_STAGED_BUNDLED_IDS } from "../plugins/staged-bundled-plugin-ids.js";
|
||||
|
||||
function writePackedPlugin(root: string): void {
|
||||
mkdirSync(join(root, "dist"), { recursive: true });
|
||||
@@ -66,7 +68,56 @@ function collectTextFiles(root: string): string[] {
|
||||
return files;
|
||||
}
|
||||
|
||||
const dependencyMapKeys = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"] as const;
|
||||
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const vendoredExtensionManifestPaths = [
|
||||
join(packageRoot, "dist", "pi-claude-cli", "package.json"),
|
||||
join(packageRoot, "dist", "droid-cli", "package.json"),
|
||||
join(packageRoot, "dist", "pi-llama-cpp", "package.json"),
|
||||
];
|
||||
|
||||
type PackageJson = {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
};
|
||||
|
||||
function readPackageJson(path: string): PackageJson {
|
||||
return JSON.parse(readFileSync(path, "utf-8"));
|
||||
}
|
||||
|
||||
function assertNoPrivateWorkspaceDependencies(path: string): void {
|
||||
const packageJson = readPackageJson(path);
|
||||
for (const dependencyMapKey of dependencyMapKeys) {
|
||||
const dependencyMap = packageJson[dependencyMapKey] ?? {};
|
||||
for (const [name, specifier] of Object.entries(dependencyMap)) {
|
||||
expect.soft(name, `${path} ${dependencyMapKey} must not depend on private @fusion/* packages`).not.toMatch(/^@fusion\//);
|
||||
expect.soft(specifier, `${path} ${dependencyMapKey}.${name} must not use workspace: specifiers`).not.toContain("workspace:");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("standalone plugin pack shape", () => {
|
||||
/*
|
||||
* FNXC:Packaging 2026-06-26-00:00:
|
||||
* FN-7060 guards the published CLI install path: every staged bundled plugin and vendored pi extension manifest shipped in dist must be free of workspace: specifiers and private @fusion/* dependency keys, or Linux npm/pnpm installs can try to resolve unpublished workspace packages and fail with a missing fusion core error.
|
||||
*/
|
||||
it("does not ship private workspace dependency references in built plugin manifests", () => {
|
||||
for (const pluginId of ALL_STAGED_BUNDLED_IDS) {
|
||||
const packageJsonPath = join(packageRoot, "dist", "plugins", pluginId, "package.json");
|
||||
if (existsSync(packageJsonPath)) {
|
||||
assertNoPrivateWorkspaceDependencies(packageJsonPath);
|
||||
}
|
||||
}
|
||||
|
||||
for (const packageJsonPath of vendoredExtensionManifestPaths) {
|
||||
if (existsSync(packageJsonPath)) {
|
||||
assertNoPrivateWorkspaceDependencies(packageJsonPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("is accepted by the loader entry seams and does not leak private workspace imports", async () => {
|
||||
const packedRoot = join(tmpdir(), `fn-plugin-pack-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
try {
|
||||
|
||||
@@ -56,6 +56,77 @@ type BundlePluginEntryOptions = {
|
||||
withMcpAsset?: boolean;
|
||||
};
|
||||
|
||||
type PackageManifest = {
|
||||
name?: string;
|
||||
version?: string;
|
||||
type?: string;
|
||||
exports?: unknown;
|
||||
main?: string;
|
||||
pi?: unknown;
|
||||
private?: boolean;
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
};
|
||||
|
||||
const dependencyMapKeys = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"] as const;
|
||||
|
||||
function isDependencyMap(value: unknown): value is Record<string, string> {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.values(value).every((specifier) => typeof specifier === "string")
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeDependencyMap(dependencyMap: unknown): Record<string, string> | undefined {
|
||||
if (!isDependencyMap(dependencyMap)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sanitized = Object.fromEntries(
|
||||
Object.entries(dependencyMap).filter(
|
||||
([name, specifier]) => !name.startsWith("@fusion/") && !specifier.includes("workspace:"),
|
||||
),
|
||||
);
|
||||
return Object.keys(sanitized).length > 0 ? sanitized : undefined;
|
||||
}
|
||||
|
||||
/*
|
||||
* FNXC:Packaging 2026-06-26-08:40:
|
||||
* Copied source manifests in the published CLI must be install-safe outside the workspace. Private @fusion/* dependencies and workspace: specifiers make package managers resolve unpublished packages during npm/npx installs, producing the FN-7060 missing fusion core failure, so raw-src plugin and pi-extension manifests are rewritten while preserving loadable entry metadata and real third-party deps.
|
||||
*/
|
||||
function writeSanitizedCopiedManifest(srcPkgPath: string, destPkgPath: string) {
|
||||
const srcPkg = JSON.parse(readFileSync(srcPkgPath, "utf-8")) as PackageManifest;
|
||||
const destPkg: PackageManifest = {
|
||||
name: srcPkg.name,
|
||||
version: srcPkg.version,
|
||||
type: srcPkg.type,
|
||||
private: true,
|
||||
};
|
||||
|
||||
if (srcPkg.exports !== undefined) {
|
||||
destPkg.exports = srcPkg.exports;
|
||||
}
|
||||
if (srcPkg.main !== undefined) {
|
||||
destPkg.main = srcPkg.main;
|
||||
}
|
||||
if (srcPkg.pi !== undefined) {
|
||||
destPkg.pi = srcPkg.pi;
|
||||
}
|
||||
|
||||
for (const dependencyMapKey of dependencyMapKeys) {
|
||||
const sanitizedDependencyMap = sanitizeDependencyMap(srcPkg[dependencyMapKey]);
|
||||
if (sanitizedDependencyMap) {
|
||||
destPkg[dependencyMapKey] = sanitizedDependencyMap;
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(destPkgPath, JSON.stringify(destPkg, null, 2));
|
||||
}
|
||||
|
||||
async function bundlePluginEntry({ pluginId, srcDir, destDir, withMcpAsset = false }: BundlePluginEntryOptions) {
|
||||
if (existsSync(destDir)) {
|
||||
rmSync(destDir, { recursive: true, force: true });
|
||||
@@ -184,7 +255,7 @@ const cliBuildConfig = {
|
||||
mkdirSync(piClaudeCliDest, { recursive: true });
|
||||
cpSync(join(piClaudeCliSrc, "index.ts"), join(piClaudeCliDest, "index.ts"));
|
||||
cpSync(join(piClaudeCliSrc, "src"), join(piClaudeCliDest, "src"), { recursive: true });
|
||||
cpSync(join(piClaudeCliSrc, "package.json"), join(piClaudeCliDest, "package.json"));
|
||||
writeSanitizedCopiedManifest(join(piClaudeCliSrc, "package.json"), join(piClaudeCliDest, "package.json"));
|
||||
console.log("Copied pi-claude-cli extension to dist/pi-claude-cli/");
|
||||
} else {
|
||||
console.warn(
|
||||
@@ -205,7 +276,7 @@ const cliBuildConfig = {
|
||||
mkdirSync(droidCliDest, { recursive: true });
|
||||
cpSync(join(droidCliSrc, "index.ts"), join(droidCliDest, "index.ts"));
|
||||
cpSync(join(droidCliSrc, "src"), join(droidCliDest, "src"), { recursive: true });
|
||||
cpSync(join(droidCliSrc, "package.json"), join(droidCliDest, "package.json"));
|
||||
writeSanitizedCopiedManifest(join(droidCliSrc, "package.json"), join(droidCliDest, "package.json"));
|
||||
console.log("Copied droid-cli extension to dist/droid-cli/");
|
||||
} else {
|
||||
console.warn(
|
||||
@@ -220,7 +291,7 @@ const cliBuildConfig = {
|
||||
mkdirSync(llamaCppDest, { recursive: true });
|
||||
cpSync(join(llamaCppSrc, "index.ts"), join(llamaCppDest, "index.ts"));
|
||||
cpSync(join(llamaCppSrc, "src"), join(llamaCppDest, "src"), { recursive: true });
|
||||
cpSync(join(llamaCppSrc, "package.json"), join(llamaCppDest, "package.json"));
|
||||
writeSanitizedCopiedManifest(join(llamaCppSrc, "package.json"), join(llamaCppDest, "package.json"));
|
||||
console.log("Copied pi-llama-cpp extension to dist/pi-llama-cpp/");
|
||||
} else {
|
||||
console.warn(
|
||||
@@ -240,7 +311,7 @@ const cliBuildConfig = {
|
||||
if (existsSync(whatsappChatPluginSrc)) {
|
||||
mkdirSync(whatsappChatPluginDest, { recursive: true });
|
||||
cpSync(join(whatsappChatPluginSrc, "manifest.json"), join(whatsappChatPluginDest, "manifest.json"));
|
||||
cpSync(join(whatsappChatPluginSrc, "package.json"), join(whatsappChatPluginDest, "package.json"));
|
||||
writeSanitizedCopiedManifest(join(whatsappChatPluginSrc, "package.json"), join(whatsappChatPluginDest, "package.json"));
|
||||
cpSync(join(whatsappChatPluginSrc, "src"), join(whatsappChatPluginDest, "src"), { recursive: true });
|
||||
console.log("Copied WhatsApp chat plugin to dist/plugins/fusion-plugin-whatsapp-chat/");
|
||||
} else {
|
||||
@@ -267,7 +338,7 @@ const cliBuildConfig = {
|
||||
if (existsSync(reportsPluginSrc)) {
|
||||
mkdirSync(reportsPluginDest, { recursive: true });
|
||||
cpSync(join(reportsPluginSrc, "manifest.json"), join(reportsPluginDest, "manifest.json"));
|
||||
cpSync(join(reportsPluginSrc, "package.json"), join(reportsPluginDest, "package.json"));
|
||||
writeSanitizedCopiedManifest(join(reportsPluginSrc, "package.json"), join(reportsPluginDest, "package.json"));
|
||||
cpSync(join(reportsPluginSrc, "src"), join(reportsPluginDest, "src"), { recursive: true });
|
||||
console.log("Copied reports plugin to dist/plugins/fusion-plugin-reports/");
|
||||
} else {
|
||||
@@ -282,7 +353,7 @@ const cliBuildConfig = {
|
||||
if (existsSync(cliPrintingPressPluginSrc)) {
|
||||
mkdirSync(cliPrintingPressPluginDest, { recursive: true });
|
||||
cpSync(join(cliPrintingPressPluginSrc, "manifest.json"), join(cliPrintingPressPluginDest, "manifest.json"));
|
||||
cpSync(join(cliPrintingPressPluginSrc, "package.json"), join(cliPrintingPressPluginDest, "package.json"));
|
||||
writeSanitizedCopiedManifest(join(cliPrintingPressPluginSrc, "package.json"), join(cliPrintingPressPluginDest, "package.json"));
|
||||
cpSync(join(cliPrintingPressPluginSrc, "src"), join(cliPrintingPressPluginDest, "src"), { recursive: true });
|
||||
console.log("Copied cli-printing-press plugin to dist/plugins/fusion-plugin-cli-printing-press/");
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user