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 966368c250
commit 2b8cbd1d05
3 changed files with 241 additions and 47 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix `fn plugin install` / `fn plugin add` path registration so local directory installs persist an absolute JavaScript entry file path instead of the source directory. This resolves plugin load failures on restart when loaders require a concrete JS module file.

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"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => { const mocks = vi.hoisted(() => {
@@ -12,29 +15,37 @@ const mocks = vi.hoisted(() => {
let loaderTaskStore: { getRootDir?: () => string } | undefined; let loaderTaskStore: { getRootDir?: () => string } | undefined;
let loaderRootDir: string | undefined; let loaderRootDir: string | undefined;
const PluginStore = vi.fn().mockImplementation(() => { const PluginStore = vi.fn();
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 PluginLoader = vi.fn().mockImplementation((options: { taskStore: { getRootDir?: () => string } }) => { const PluginLoader = vi.fn();
loaderTaskStore = options.taskStore;
return { const setupDefaults = () => {
loadPlugin: vi.fn().mockImplementation(async () => { PluginStore.mockImplementation(() => {
loaderRootDir = options.taskStore.getRootDir?.(); 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 { return {
PluginStore, PluginStore,
@@ -46,8 +57,9 @@ const mocks = vi.hoisted(() => {
loaderTaskStore = undefined; loaderTaskStore = undefined;
loaderRootDir = undefined; loaderRootDir = undefined;
pluginStoreInstances.length = 0; pluginStoreInstances.length = 0;
PluginStore.mockClear(); PluginStore.mockReset();
PluginLoader.mockClear(); PluginLoader.mockReset();
setupDefaults();
}, },
}; };
}); });
@@ -62,24 +74,38 @@ vi.mock("../../project-context.js", () => ({
resolveProject: vi.fn().mockResolvedValue({ projectPath: "/tmp/fn-project" }), resolveProject: vi.fn().mockResolvedValue({ projectPath: "/tmp/fn-project" }),
})); }));
vi.mock("node:fs", () => ({ vi.mock("node:fs", async () => {
existsSync: vi.fn().mockReturnValue(true), 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", () => ({ import {
readFile: vi.fn().mockResolvedValue( resolvePluginEntryFile,
JSON.stringify({ runPluginAvailable,
id: "paperclip-runtime", runPluginInstall,
name: "Paperclip Runtime", runPluginSettings,
version: "1.0.0", runPluginRescan,
}), } from "../plugin.js";
),
}));
import { runPluginAvailable, runPluginInstall, runPluginSettings, runPluginRescan } from "../plugin.js";
import { resolveProject } from "../../project-context.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", () => { describe("plugin commands", () => {
const tempDirs: string[] = [];
beforeEach(() => { beforeEach(() => {
mocks.reset(); mocks.reset();
vi.mocked(resolveProject).mockResolvedValue({ projectPath: "/tmp/fn-project" } as never); vi.mocked(resolveProject).mockResolvedValue({ projectPath: "/tmp/fn-project" } as never);
@@ -87,12 +113,51 @@ describe("plugin commands", () => {
vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {});
}); });
afterEach(() => { afterEach(async () => {
vi.restoreAllMocks(); 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 () => { 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(); const taskStore = mocks.getLoaderTaskStore();
expect(taskStore).toBeDefined(); expect(taskStore).toBeDefined();
@@ -101,6 +166,31 @@ describe("plugin commands", () => {
expect(mocks.getLoaderRootDir()).toBe("/tmp/fn-project"); 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 () => { it("prints built-in plugin catalog", async () => {
await expect(runPluginAvailable()).resolves.toBeUndefined(); await expect(runPluginAvailable()).resolves.toBeUndefined();
expect(console.log).toHaveBeenCalledWith(expect.stringContaining("Installable")); expect(console.log).toHaveBeenCalledWith(expect.stringContaining("Installable"));

View File

@@ -10,8 +10,8 @@
*/ */
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { join } from "node:path"; import { dirname, extname, join, resolve } from "node:path";
import { readFile } from "node:fs/promises"; import { readFile, stat } from "node:fs/promises";
import * as readline from "node:readline"; import * as readline from "node:readline";
import { PluginStore, PluginLoader, validatePluginManifest } from "@fusion/core"; import { PluginStore, PluginLoader, validatePluginManifest } from "@fusion/core";
import { resolveProject } from "../project-context.js"; import { resolveProject } from "../project-context.js";
@@ -123,13 +123,111 @@ async function createPluginLoader(
return { store: pluginStore, loader }; 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. * Load plugin manifest from a local path.
*/ */
async function loadManifestFromPath( async function loadManifestFromPath(
pluginPath: string, pluginPath: string,
): Promise<{ manifest: import("@fusion/core").PluginManifest; path: 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)) { if (!existsSync(manifestPath)) {
throw new Error(`Plugin manifest not found at: ${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(", ")}`); 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 { try {
const { manifest, path } = await loadManifestFromPath(source); const entryPath = await resolvePluginEntryFile(source);
const { manifest } = await loadManifestFromPath(source);
console.log(); console.log();
console.log(` Installing ${manifest.name} v${manifest.version} globally...`); console.log(` Installing ${manifest.name} v${manifest.version} globally...`);
@@ -226,7 +325,7 @@ export async function runPluginInstall(
// Register the plugin // Register the plugin
const plugin = await store.registerPlugin({ const plugin = await store.registerPlugin({
manifest, manifest,
path, path: entryPath,
aiScanOnLoad: options?.aiScan ?? false, aiScanOnLoad: options?.aiScan ?? false,
}); });