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:
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user