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
146 lines
5.3 KiB
TypeScript
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 });
|
|
}
|
|
});
|
|
});
|