Files
fusion/packages/cli/src/__tests__/plugin-pack-shape.test.ts
gsxdsm 42f46a12c1 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
2026-06-26 10:18:04 -07:00

146 lines
5.3 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
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 });
writeFileSync(
join(root, "package.json"),
JSON.stringify(
{
name: "fusion-plugin-packed-test",
version: "0.1.0",
type: "module",
exports: {
".": {
types: "./dist/index.d.ts",
import: "./dist/index.js",
},
},
files: ["dist", "manifest.json"],
devDependencies: {
"@runfusion/fusion": "^0.1.0",
},
},
null,
2,
),
);
writeFileSync(
join(root, "manifest.json"),
JSON.stringify(
{
id: "fusion-plugin-packed-test",
name: "Packed Test",
version: "0.1.0",
description: "Synthetic standalone packed plugin artifact.",
},
null,
2,
),
);
writeFileSync(
join(root, "dist", "index.js"),
"import { definePlugin } from '@runfusion/fusion/plugin-sdk';\nexport default definePlugin({ manifest: { id: 'fusion-plugin-packed-test', name: 'Packed Test', version: '0.1.0' } });\n",
);
writeFileSync(join(root, "dist", "index.d.ts"), "export {};\n");
}
function collectTextFiles(root: string): string[] {
const files: string[] = [];
const visit = (dir: string): void => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
visit(fullPath);
continue;
}
if ([".js", ".mjs", ".cjs", ".json", ".ts", ".d.ts"].includes(extname(fullPath))) {
files.push(fullPath);
}
}
};
visit(root);
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 {
writePackedPlugin(packedRoot);
const { manifest, path } = await loadManifestFromPath(packedRoot);
expect(path).toBe(packedRoot);
expect(manifest).toMatchObject({
id: "fusion-plugin-packed-test",
name: "Packed Test",
version: "0.1.0",
});
await expect(resolvePluginEntryFile(packedRoot)).resolves.toBe(join(packedRoot, "dist", "index.js"));
const contents = collectTextFiles(packedRoot).map((file) => readFileSync(file, "utf-8"));
expect(contents.length).toBeGreaterThan(0);
for (const content of contents) {
expect(content).not.toContain("@fusion/");
expect(content).not.toContain("workspace:");
}
} finally {
rmSync(packedRoot, { recursive: true, force: true });
}
});
});