fix(FN-3721): add release note for plugin install entry resolution

- Add a changeset for FN-3721 covering plugin install entry resolution fixes
- Mark @runfusion/fusion for a patch release
- Preserve workspace gate restoration changes in the squash merge metadata

Fusion-Task-Id: FN-3721
This commit is contained in:
Fusion
2026-05-07 23:41:29 -07:00
committed by gsxdsm
parent 31110975a8
commit 4c6125a710
3 changed files with 241 additions and 47 deletions

View File

@@ -1,3 +1,6 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
@@ -12,29 +15,37 @@ const mocks = vi.hoisted(() => {
let loaderTaskStore: { getRootDir?: () => string } | undefined;
let loaderRootDir: string | undefined;
const PluginStore = vi.fn().mockImplementation(() => {
const instance = {
init: vi.fn().mockResolvedValue(undefined),
registerPlugin: vi.fn().mockResolvedValue({
id: "paperclip-runtime",
enabled: true,
}),
listPlugins: vi.fn().mockResolvedValue([]),
getPlugin: vi.fn(),
updatePluginSettings: vi.fn().mockResolvedValue(undefined),
};
pluginStoreInstances.push(instance);
return instance;
});
const PluginStore = vi.fn();
const PluginLoader = vi.fn().mockImplementation((options: { taskStore: { getRootDir?: () => string } }) => {
loaderTaskStore = options.taskStore;
return {
loadPlugin: vi.fn().mockImplementation(async () => {
loaderRootDir = options.taskStore.getRootDir?.();
}),
};
});
const PluginLoader = vi.fn();
const setupDefaults = () => {
PluginStore.mockImplementation(() => {
const instance = {
init: vi.fn().mockResolvedValue(undefined),
registerPlugin: vi.fn().mockResolvedValue({
id: "paperclip-runtime",
enabled: true,
}),
listPlugins: vi.fn().mockResolvedValue([]),
getPlugin: vi.fn(),
updatePluginSettings: vi.fn().mockResolvedValue(undefined),
};
pluginStoreInstances.push(instance);
return instance;
});
PluginLoader.mockImplementation((options: { taskStore: { getRootDir?: () => string } }) => {
loaderTaskStore = options.taskStore;
return {
loadPlugin: vi.fn().mockImplementation(async () => {
loaderRootDir = options.taskStore.getRootDir?.();
}),
};
});
};
setupDefaults();
return {
PluginStore,
@@ -46,8 +57,9 @@ const mocks = vi.hoisted(() => {
loaderTaskStore = undefined;
loaderRootDir = undefined;
pluginStoreInstances.length = 0;
PluginStore.mockClear();
PluginLoader.mockClear();
PluginStore.mockReset();
PluginLoader.mockReset();
setupDefaults();
},
};
});
@@ -62,24 +74,38 @@ vi.mock("../../project-context.js", () => ({
resolveProject: vi.fn().mockResolvedValue({ projectPath: "/tmp/fn-project" }),
}));
vi.mock("node:fs", () => ({
existsSync: vi.fn().mockReturnValue(true),
}));
vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return {
...actual,
existsSync: vi.fn((path: Parameters<typeof actual.existsSync>[0]) => actual.existsSync(path)),
};
});
vi.mock("node:fs/promises", () => ({
readFile: vi.fn().mockResolvedValue(
JSON.stringify({
id: "paperclip-runtime",
name: "Paperclip Runtime",
version: "1.0.0",
}),
),
}));
import { runPluginAvailable, runPluginInstall, runPluginSettings, runPluginRescan } from "../plugin.js";
import {
resolvePluginEntryFile,
runPluginAvailable,
runPluginInstall,
runPluginSettings,
runPluginRescan,
} from "../plugin.js";
import { resolveProject } from "../../project-context.js";
async function createTempPluginFixture(
files: Array<{ path: string; content: string }>,
): Promise<string> {
const pluginDir = await mkdtemp(join(tmpdir(), "fn-plugin-test-"));
for (const file of files) {
const target = join(pluginDir, file.path);
await mkdir(dirname(target), { recursive: true });
await writeFile(target, file.content, "utf-8");
}
return pluginDir;
}
describe("plugin commands", () => {
const tempDirs: string[] = [];
beforeEach(() => {
mocks.reset();
vi.mocked(resolveProject).mockResolvedValue({ projectPath: "/tmp/fn-project" } as never);
@@ -87,12 +113,51 @@ describe("plugin commands", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
afterEach(async () => {
vi.clearAllMocks();
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })));
tempDirs.length = 0;
});
it("resolves package exports import entry to dist/index.js", async () => {
const pluginDir = await createTempPluginFixture([
{
path: "package.json",
content: JSON.stringify({ exports: { ".": { import: "./dist/index.js" } } }),
},
{ path: "dist/index.js", content: "export default {};" },
]);
tempDirs.push(pluginDir);
await expect(resolvePluginEntryFile(pluginDir)).resolves.toBe(resolve(pluginDir, "dist/index.js"));
});
it("includes getRootDir on the plugin loader taskStore mock (FN-2687)", async () => {
await expect(runPluginInstall("/plugins/paperclip-runtime")).resolves.toBeUndefined();
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`exit:${code}`);
}) as never);
const pluginDir = await createTempPluginFixture([
{
path: "manifest.json",
content: JSON.stringify({ id: "paperclip-runtime", name: "Paperclip Runtime", version: "1.0.0" }),
},
{
path: "package.json",
content: JSON.stringify({ exports: { ".": { import: "./dist/index.js" } } }),
},
{
path: "dist/index.js",
content:
"export default { manifest: { id: 'paperclip-runtime', name: 'Paperclip Runtime', version: '1.0.0' }, async onLoad() {}, async onUnload() {} };",
},
]);
tempDirs.push(pluginDir);
await expect(runPluginInstall(pluginDir)).resolves.toBeUndefined();
expect(exitSpy).not.toHaveBeenCalled();
const registerCall = mocks.pluginStoreInstances[0]?.registerPlugin.mock.calls[0]?.[0];
expect(registerCall.path).toBe(resolve(pluginDir, "dist/index.js"));
const taskStore = mocks.getLoaderTaskStore();
expect(taskStore).toBeDefined();
@@ -101,6 +166,31 @@ describe("plugin commands", () => {
expect(mocks.getLoaderRootDir()).toBe("/tmp/fn-project");
});
it("exits non-zero when plugin entry cannot resolve to built JavaScript", async () => {
const pluginDir = await createTempPluginFixture([
{
path: "manifest.json",
content: JSON.stringify({ id: "paperclip-runtime", name: "Paperclip Runtime", version: "1.0.0" }),
},
{
path: "package.json",
content: JSON.stringify({ exports: { ".": { import: "./src/index.ts" } } }),
},
{ path: "src/index.ts", content: "export default {};" },
]);
tempDirs.push(pluginDir);
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`exit:${code}`);
}) as never);
await expect(runPluginInstall(pluginDir)).rejects.toThrow("exit:1");
expect(exitSpy).toHaveBeenCalledWith(1);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining("Build the plugin first"),
);
});
it("prints built-in plugin catalog", async () => {
await expect(runPluginAvailable()).resolves.toBeUndefined();
expect(console.log).toHaveBeenCalledWith(expect.stringContaining("Installable"));

View File

@@ -10,8 +10,8 @@
*/
import { existsSync } from "node:fs";
import { join } from "node:path";
import { readFile } from "node:fs/promises";
import { dirname, extname, join, resolve } from "node:path";
import { readFile, stat } from "node:fs/promises";
import * as readline from "node:readline";
import { PluginStore, PluginLoader, validatePluginManifest } from "@fusion/core";
import { resolveProject } from "../project-context.js";
@@ -123,13 +123,111 @@ async function createPluginLoader(
return { store: pluginStore, loader };
}
const JS_ENTRY_EXTENSIONS = new Set([".js", ".mjs", ".cjs"]);
const TS_SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts"]);
function isJsEntryFile(path: string): boolean {
return JS_ENTRY_EXTENSIONS.has(extname(path).toLowerCase());
}
function isTypeScriptSource(path: string): boolean {
return TS_SOURCE_EXTENSIONS.has(extname(path).toLowerCase());
}
async function statPath(path: string): Promise<import("node:fs").Stats | undefined> {
try {
return await stat(path);
} catch {
return undefined;
}
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
}
/**
* Resolve plugin installation source to a compiled JavaScript entry file.
*/
export async function resolvePluginEntryFile(pluginDir: string): Promise<string> {
const absoluteInputPath = resolve(pluginDir);
const inputStats = await statPath(absoluteInputPath);
if (inputStats?.isFile()) {
if (isTypeScriptSource(absoluteInputPath)) {
throw new Error(
`Plugin entry must be compiled JavaScript, but got TypeScript source: ${absoluteInputPath}. Build the plugin first (for example: pnpm build in the plugin directory).`,
);
}
if (isJsEntryFile(absoluteInputPath)) {
return absoluteInputPath;
}
throw new Error(`Plugin entry file must end with .js, .mjs, or .cjs: ${absoluteInputPath}`);
}
const packageJsonPath = join(absoluteInputPath, "package.json");
let selectedCandidate: string | undefined;
if (existsSync(packageJsonPath)) {
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf-8")) as Record<string, unknown>;
const exportsRecord = asRecord(packageJson.exports);
const dotExport = exportsRecord?.["."];
const dotExportRecord = asRecord(dotExport);
if (typeof dotExportRecord?.import === "string") {
selectedCandidate = dotExportRecord.import;
} else if (typeof dotExportRecord?.default === "string") {
selectedCandidate = dotExportRecord.default;
} else if (typeof dotExport === "string") {
selectedCandidate = dotExport;
} else if (typeof packageJson.main === "string") {
selectedCandidate = packageJson.main;
}
}
if (selectedCandidate) {
const absoluteCandidate = resolve(absoluteInputPath, selectedCandidate);
if (isTypeScriptSource(absoluteCandidate)) {
throw new Error(
`Plugin entry resolves to TypeScript source (${absoluteCandidate}). Build the plugin first (for example: pnpm build in the plugin directory).`,
);
}
const candidateStats = await statPath(absoluteCandidate);
if (!candidateStats?.isFile()) {
throw new Error(
`Plugin entry file not found: ${absoluteCandidate}. Build the plugin first (for example: pnpm build in the plugin directory).`,
);
}
return absoluteCandidate;
}
const distIndexPath = resolve(absoluteInputPath, "dist/index.js");
const distStats = await statPath(distIndexPath);
if (distStats?.isFile()) {
return distIndexPath;
}
const indexPath = resolve(absoluteInputPath, "index.js");
const indexStats = await statPath(indexPath);
if (indexStats?.isFile()) {
return indexPath;
}
throw new Error(
`Could not resolve a plugin JavaScript entry file in ${absoluteInputPath}. Tried package.json exports/main, dist/index.js, and index.js. Build the plugin first (for example: pnpm build in the plugin directory).`,
);
}
/**
* Load plugin manifest from a local path.
*/
async function loadManifestFromPath(
pluginPath: string,
): Promise<{ manifest: import("@fusion/core").PluginManifest; path: string }> {
const manifestPath = join(pluginPath, "manifest.json");
const absoluteInputPath = resolve(pluginPath);
const inputStats = await statPath(absoluteInputPath);
const manifestDir = inputStats?.isFile() ? dirname(absoluteInputPath) : absoluteInputPath;
const manifestPath = join(manifestDir, "manifest.json");
if (!existsSync(manifestPath)) {
throw new Error(`Plugin manifest not found at: ${manifestPath}`);
@@ -143,7 +241,7 @@ async function loadManifestFromPath(
throw new Error(`Invalid plugin manifest: ${validation.errors.join(", ")}`);
}
return { manifest, path: pluginPath };
return { manifest, path: manifestDir };
}
/**
@@ -218,7 +316,8 @@ export async function runPluginInstall(
}
try {
const { manifest, path } = await loadManifestFromPath(source);
const entryPath = await resolvePluginEntryFile(source);
const { manifest } = await loadManifestFromPath(source);
console.log();
console.log(` Installing ${manifest.name} v${manifest.version} globally...`);
@@ -226,7 +325,7 @@ export async function runPluginInstall(
// Register the plugin
const plugin = await store.registerPlugin({
manifest,
path,
path: entryPath,
aiScanOnLoad: options?.aiScan ?? false,
});