fix: restore plugin SDK and Todo packaging
This commit is contained in:
7
.changeset/fix-plugin-sdk-declarations.md
Normal file
7
.changeset/fix-plugin-sdk-declarations.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Restore plugin SDK declarations and Todo Lists packaging.
|
||||
category: fix
|
||||
dev: Full releases typecheck the SDK entry; the Todo bundle now receives AgentStore from the runtime shim.
|
||||
@@ -1,7 +1,23 @@
|
||||
/* global process, URL, console */
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { existsSync, readFileSync, statSync, writeFileSync, unlinkSync } from "node:fs";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
export function assertPluginSdkDeclarationExists(packageRoot) {
|
||||
const declarationPath = join(packageRoot, "dist", "plugin-sdk", "index.d.ts");
|
||||
let declarationSize = 0;
|
||||
try {
|
||||
declarationSize = statSync(declarationPath).size;
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
if (declarationSize === 0) {
|
||||
throw new Error(
|
||||
`Refusing to pack @runfusion/fusion: missing or empty dist/plugin-sdk/index.d.ts. Run the full package build first.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function applyPrepackTransform(pkg) {
|
||||
const devDependencies = { ...(pkg.devDependencies || {}) };
|
||||
@@ -42,28 +58,29 @@ function run() {
|
||||
const backupPath = new URL("../package.json.pack-backup", import.meta.url);
|
||||
|
||||
if (mode === "prepack") {
|
||||
if (existsSync(backupPath)) {
|
||||
unlinkSync(backupPath);
|
||||
}
|
||||
if (existsSync(backupPath)) {
|
||||
unlinkSync(backupPath);
|
||||
}
|
||||
assertPluginSdkDeclarationExists(dirname(fileURLToPath(packageJsonPath)));
|
||||
|
||||
const original = readFileSync(packageJsonPath, "utf8");
|
||||
writeFileSync(backupPath, original, "utf8");
|
||||
const original = readFileSync(packageJsonPath, "utf8");
|
||||
writeFileSync(backupPath, original, "utf8");
|
||||
|
||||
const pkg = JSON.parse(original);
|
||||
const transformed = applyPrepackTransform(pkg);
|
||||
writeFileSync(packageJsonPath, `${JSON.stringify(transformed, null, 2)}\n`, "utf8");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (mode === "postpack") {
|
||||
if (!existsSync(backupPath)) {
|
||||
const pkg = JSON.parse(original);
|
||||
const transformed = applyPrepackTransform(pkg);
|
||||
writeFileSync(packageJsonPath, `${JSON.stringify(transformed, null, 2)}\n`, "utf8");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const backup = readFileSync(backupPath, "utf8");
|
||||
writeFileSync(packageJsonPath, backup, "utf8");
|
||||
unlinkSync(backupPath);
|
||||
process.exit(0);
|
||||
if (mode === "postpack") {
|
||||
if (!existsSync(backupPath)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const backup = readFileSync(backupPath, "utf8");
|
||||
writeFileSync(packageJsonPath, backup, "utf8");
|
||||
unlinkSync(backupPath);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error("Usage: node ./scripts/prepare-publish-manifest.mjs <prepack|postpack>");
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { stop as stopEsbuild } from "esbuild";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -9,7 +14,10 @@ vi.mock("node:child_process", () => ({
|
||||
spawn: mocks.spawn,
|
||||
}));
|
||||
|
||||
import { superviseSpawn } from "../plugin-sdk-core-runtime-shim.mjs";
|
||||
import { AgentStore, superviseSpawn } from "../plugin-sdk-core-runtime-shim.mjs";
|
||||
import { bundlePluginEntry } from "../../tsup.config";
|
||||
|
||||
const workspaceRoot = join(__dirname, "..", "..", "..", "..");
|
||||
|
||||
class FakeChild extends EventEmitter {
|
||||
pid = 1234;
|
||||
@@ -26,6 +34,32 @@ describe("plugin SDK core runtime shim supervision", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("exposes AgentStore to bundled plugins without a private core dependency", () => {
|
||||
expect(AgentStore).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("bundles and loads the Todo plugin through the production core alias", async () => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "fusion-todo-plugin-bundle-"));
|
||||
const destDir = join(tempRoot, "fusion-plugin-todos");
|
||||
try {
|
||||
await bundlePluginEntry({
|
||||
pluginId: "fusion-plugin-todos",
|
||||
srcDir: join(workspaceRoot, "plugins", "fusion-plugin-todos"),
|
||||
destDir,
|
||||
});
|
||||
|
||||
const bundledPath = join(destDir, "bundled.js");
|
||||
expect(readFileSync(bundledPath, "utf8")).not.toMatch(
|
||||
/(?:from\s+|import\s*(?:\(\s*)?|require\s*\(\s*)["']@fusion\//,
|
||||
);
|
||||
const plugin = await import(pathToFileURL(bundledPath).href);
|
||||
expect(plugin.default.manifest.id).toBe("fusion-plugin-todos");
|
||||
} finally {
|
||||
stopEsbuild();
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("absorbs child spawn errors without throwing", () => {
|
||||
const child = new FakeChild();
|
||||
mocks.spawn.mockReturnValue(child);
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import ts from "typescript";
|
||||
import { definePlugin, validatePluginManifest } from "@fusion/plugin-sdk";
|
||||
import { applyPrepackTransform } from "../../scripts/prepare-publish-manifest.mjs";
|
||||
import {
|
||||
applyPrepackTransform,
|
||||
assertPluginSdkDeclarationExists,
|
||||
} from "../../scripts/prepare-publish-manifest.mjs";
|
||||
|
||||
const workspaceRoot = join(__dirname, "..", "..", "..", "..");
|
||||
|
||||
@@ -61,6 +72,28 @@ describe("plugin-sdk export surface", () => {
|
||||
expect(transformed.pi).toEqual(pkg.pi);
|
||||
});
|
||||
|
||||
it("refuses to pack a types export without the plugin-sdk declaration", () => {
|
||||
const packageRoot = mkdtempSync(join(tmpdir(), "fusion-plugin-sdk-prepack-"));
|
||||
try {
|
||||
expect(() => assertPluginSdkDeclarationExists(packageRoot)).toThrow(
|
||||
/dist\/plugin-sdk\/index\.d\.ts/,
|
||||
);
|
||||
|
||||
const declarationPath = join(packageRoot, "dist", "plugin-sdk", "index.d.ts");
|
||||
mkdirSync(join(packageRoot, "dist", "plugin-sdk"), { recursive: true });
|
||||
writeFileSync(declarationPath, "");
|
||||
expect(() => assertPluginSdkDeclarationExists(packageRoot)).toThrow(
|
||||
/dist\/plugin-sdk\/index\.d\.ts/,
|
||||
);
|
||||
|
||||
writeFileSync(declarationPath, "export declare const definePlugin: unknown;\n");
|
||||
|
||||
expect(() => assertPluginSdkDeclarationExists(packageRoot)).not.toThrow();
|
||||
} finally {
|
||||
rmSync(packageRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("declares plugin-sdk tsup build entry with dts and fusion inlining", () => {
|
||||
const tsupPath = join(workspaceRoot, "packages", "cli", "tsup.config.ts");
|
||||
const tsupRaw = readFileSync(tsupPath, "utf-8");
|
||||
@@ -124,6 +157,9 @@ describe("plugin-sdk export surface", () => {
|
||||
return;
|
||||
}
|
||||
const built = readFileSync(distPath, "utf-8");
|
||||
expect(built.includes("@fusion/")).toBe(false);
|
||||
const fusionTypeSpecifiers = executableModuleSpecifiers(built).filter((specifier) =>
|
||||
specifier.startsWith("@fusion/"),
|
||||
);
|
||||
expect(fusionTypeSpecifiers).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,10 +3,11 @@ import { spawn } from "node:child_process";
|
||||
/*
|
||||
* FNXC:BundledPlugins 2026-07-15-13:40:
|
||||
* Clean CI typechecks the CLI before @fusion/core emits dist, but published
|
||||
* bundled plugins need both postgresSchema runtime values and Quality's
|
||||
* process-group supervisor. Keep this alias implementation in untyped MJS so
|
||||
* tsc stays inside the CLI root while esbuild follows the core source schema
|
||||
* and bundles every runtime export without a private @fusion/core dependency.
|
||||
* bundled plugins import selected core runtime values through this alias
|
||||
* (currently postgresSchema, AgentStore, and Quality's process supervisor).
|
||||
* Keep this implementation in untyped MJS so tsc stays inside the CLI root
|
||||
* while esbuild follows core source and bundles each required runtime export
|
||||
* without a private @fusion/core dependency.
|
||||
*/
|
||||
import * as postgresSchema from "../../core/src/postgres/schema/index.js";
|
||||
import { AgentStore } from "../../core/src/agents/agent-store.js";
|
||||
|
||||
@@ -168,7 +168,7 @@ function writeSanitizedCopiedManifest(srcPkgPath: string, destPkgPath: string) {
|
||||
writeFileSync(destPkgPath, JSON.stringify(destPkg, null, 2));
|
||||
}
|
||||
|
||||
async function bundlePluginEntry({ pluginId, srcDir, destDir, withMcpAsset = false, external = [] }: BundlePluginEntryOptions) {
|
||||
export async function bundlePluginEntry({ pluginId, srcDir, destDir, withMcpAsset = false, external = [] }: BundlePluginEntryOptions) {
|
||||
if (existsSync(destDir)) {
|
||||
rmSync(destDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
28
scripts/__tests__/release-package-contract.test.mjs
Normal file
28
scripts/__tests__/release-package-contract.test.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { URL } from "node:url";
|
||||
|
||||
const source = readFileSync(new URL("../release.mjs", import.meta.url), "utf8");
|
||||
|
||||
test("local releases build the complete published package surface", () => {
|
||||
assert.match(
|
||||
source,
|
||||
/info\("Building all packages…"\);\s*run\("pnpm build:full"\);/,
|
||||
"release.mjs must enable the full CLI package build before packing",
|
||||
);
|
||||
});
|
||||
|
||||
test("release smoke typechecks a consumer of the packed plugin SDK", () => {
|
||||
const smokeStart = source.indexOf("function runReleaseSmoke()");
|
||||
const smokeEnd = source.indexOf("function cleanupSmoke", smokeStart);
|
||||
const smoke = source.slice(smokeStart, smokeEnd);
|
||||
|
||||
assert.notEqual(smokeStart, -1);
|
||||
assert.notEqual(smokeEnd, -1);
|
||||
assert.match(smoke, /plugin-sdk-consumer\.ts/);
|
||||
assert.match(smoke, /@runfusion\/fusion\/plugin-sdk/);
|
||||
assert.match(smoke, /spawnSync\(\s*"pnpm",\s*\["exec", "tsc", "--project", consumerTsconfigPath\]/);
|
||||
assert.match(smoke, /timeout: 120_000/);
|
||||
assert.match(smoke, /if \(typecheck\.status !== 0\)/);
|
||||
});
|
||||
@@ -482,6 +482,7 @@ function runReleaseSmoke() {
|
||||
name: "fusion-smoke-test",
|
||||
version: "0.0.0",
|
||||
private: true,
|
||||
type: "module",
|
||||
overrides: { "@runfusion/fusion": `file:${fusionTarballPath}` },
|
||||
},
|
||||
null,
|
||||
@@ -520,6 +521,49 @@ function runReleaseSmoke() {
|
||||
);
|
||||
}
|
||||
|
||||
// Issue #3320: a fast local release build emitted the plugin-sdk runtime but
|
||||
// omitted the declaration targeted by exports["./plugin-sdk"]. Exercise the
|
||||
// published boundary with TypeScript so a missing declaration entrypoint
|
||||
// blocks release instead of shipping an unusable SDK contract.
|
||||
const consumerPath = join(installDir, "plugin-sdk-consumer.ts");
|
||||
const consumerTsconfigPath = join(installDir, "plugin-sdk-consumer.tsconfig.json");
|
||||
writeFileSync(
|
||||
consumerPath,
|
||||
'import { definePlugin } from "@runfusion/fusion/plugin-sdk";\nvoid definePlugin;\n',
|
||||
);
|
||||
writeFileSync(
|
||||
consumerTsconfigPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
compilerOptions: {
|
||||
module: "NodeNext",
|
||||
moduleResolution: "NodeNext",
|
||||
noEmit: true,
|
||||
// The public contract here is subpath resolution. Existing SDK
|
||||
// declarations can reference optional database types, so checking
|
||||
// their internals would conflate that separate compatibility issue
|
||||
// with a missing declaration entrypoint.
|
||||
skipLibCheck: true,
|
||||
strict: true,
|
||||
},
|
||||
files: [consumerPath],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
const typecheck = spawnSync(
|
||||
"pnpm",
|
||||
["exec", "tsc", "--project", consumerTsconfigPath],
|
||||
{ cwd: repoRoot, stdio: "pipe", encoding: "utf8", timeout: 120_000 },
|
||||
);
|
||||
if (typecheck.status !== 0) {
|
||||
cleanupSmoke(smokeDir);
|
||||
fail(
|
||||
`Packed plugin SDK failed consumer typecheck${typecheck.error ? `: ${typecheck.error.message}` : ""}${typecheck.signal ? ` (signal ${typecheck.signal})` : ""}:\n--- stdout ---\n${typecheck.stdout}\n--- stderr ---\n${typecheck.stderr}`,
|
||||
);
|
||||
}
|
||||
|
||||
cleanupSmoke(smokeDir);
|
||||
}
|
||||
|
||||
@@ -1055,7 +1099,7 @@ if (changelogAfterDistill !== changelogBeforeDistill) {
|
||||
// --- Build ----------------------------------------------------------------
|
||||
|
||||
info("Building all packages…");
|
||||
run("pnpm build");
|
||||
run("pnpm build:full");
|
||||
|
||||
// --- Commit ---------------------------------------------------------------
|
||||
|
||||
@@ -1188,4 +1232,3 @@ console.log(color(36, `─── Draft post for X (${CHANNEL}, copy-paste) ─
|
||||
console.log(releaseTweet);
|
||||
console.log(color(90, `(${releaseTweet.length}/280 chars; source: ${distillSource})`));
|
||||
console.log(color(36, "──────────────────────────────────────────────"));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user