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:
gsxdsm
2026-06-26 08:55:36 -07:00
parent 819c626466
commit 42f46a12c1
4 changed files with 142 additions and 11 deletions

View 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.

View File

@@ -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"
}
}
```

View File

@@ -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 {

View File

@@ -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 {