From 42f46a12c1821c35adefbd6d6f4fa37e1cdd12f0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 26 Jun 2026 08:55:36 -0700 Subject: [PATCH] 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 --- ...7060-fix-plugin-manifest-workspace-deps.md | 7 ++ docs/PLUGIN_AUTHORING.md | 8 +- .../src/__tests__/plugin-pack-shape.test.ts | 55 +++++++++++- packages/cli/tsup.config.ts | 83 +++++++++++++++++-- 4 files changed, 142 insertions(+), 11 deletions(-) create mode 100644 .changeset/fn-7060-fix-plugin-manifest-workspace-deps.md diff --git a/.changeset/fn-7060-fix-plugin-manifest-workspace-deps.md b/.changeset/fn-7060-fix-plugin-manifest-workspace-deps.md new file mode 100644 index 0000000000..77074f63e5 --- /dev/null +++ b/.changeset/fn-7060-fix-plugin-manifest-workspace-deps.md @@ -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. diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index 65cb2c3370..f243c1865b 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -1194,6 +1194,8 @@ For end-to-end standalone packaging, `pnpm pack`, and installing on another mach ### Package Requirements + + ```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" } } ``` diff --git a/packages/cli/src/__tests__/plugin-pack-shape.test.ts b/packages/cli/src/__tests__/plugin-pack-shape.test.ts index 82f965322d..8610603668 100644 --- a/packages/cli/src/__tests__/plugin-pack-shape.test.ts +++ b/packages/cli/src/__tests__/plugin-pack-shape.test.ts @@ -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; + devDependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; +}; + +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 { diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 12b92f2dc6..c3bc768a14 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -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; + devDependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; +}; + +const dependencyMapKeys = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"] as const; + +function isDependencyMap(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.values(value).every((specifier) => typeof specifier === "string") + ); +} + +function sanitizeDependencyMap(dependencyMap: unknown): Record | 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 {