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:
5
.changeset/fn-3721-plugin-install-entry-resolution.md
Normal file
5
.changeset/fn-3721-plugin-install-entry-resolution.md
Normal 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.
|
||||||
@@ -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,7 +15,12 @@ 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 PluginLoader = vi.fn();
|
||||||
|
|
||||||
|
const setupDefaults = () => {
|
||||||
|
PluginStore.mockImplementation(() => {
|
||||||
const instance = {
|
const instance = {
|
||||||
init: vi.fn().mockResolvedValue(undefined),
|
init: vi.fn().mockResolvedValue(undefined),
|
||||||
registerPlugin: vi.fn().mockResolvedValue({
|
registerPlugin: vi.fn().mockResolvedValue({
|
||||||
@@ -27,7 +35,7 @@ const mocks = vi.hoisted(() => {
|
|||||||
return instance;
|
return instance;
|
||||||
});
|
});
|
||||||
|
|
||||||
const PluginLoader = vi.fn().mockImplementation((options: { taskStore: { getRootDir?: () => string } }) => {
|
PluginLoader.mockImplementation((options: { taskStore: { getRootDir?: () => string } }) => {
|
||||||
loaderTaskStore = options.taskStore;
|
loaderTaskStore = options.taskStore;
|
||||||
return {
|
return {
|
||||||
loadPlugin: vi.fn().mockImplementation(async () => {
|
loadPlugin: vi.fn().mockImplementation(async () => {
|
||||||
@@ -35,6 +43,9 @@ const mocks = vi.hoisted(() => {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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"));
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user