From 56819e21e954ac63fd93d44dce114a499beb008a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 3 Aug 2026 12:01:42 -0700 Subject: [PATCH] fix: restore plugin SDK and Todo packaging --- .changeset/fix-plugin-sdk-declarations.md | 7 +++ .../cli/scripts/prepare-publish-manifest.mjs | 55 ++++++++++++------- .../plugin-sdk-core-runtime-shim.test.ts | 36 +++++++++++- .../src/__tests__/plugin-sdk-export.test.ts | 42 +++++++++++++- .../cli/src/plugin-sdk-core-runtime-shim.mjs | 9 +-- packages/cli/tsup.config.ts | 2 +- .../release-package-contract.test.mjs | 28 ++++++++++ scripts/release.mjs | 47 +++++++++++++++- 8 files changed, 196 insertions(+), 30 deletions(-) create mode 100644 .changeset/fix-plugin-sdk-declarations.md create mode 100644 scripts/__tests__/release-package-contract.test.mjs diff --git a/.changeset/fix-plugin-sdk-declarations.md b/.changeset/fix-plugin-sdk-declarations.md new file mode 100644 index 0000000000..c33bd9575a --- /dev/null +++ b/.changeset/fix-plugin-sdk-declarations.md @@ -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. diff --git a/packages/cli/scripts/prepare-publish-manifest.mjs b/packages/cli/scripts/prepare-publish-manifest.mjs index 9cf71fc8e8..e5a1faff8f 100644 --- a/packages/cli/scripts/prepare-publish-manifest.mjs +++ b/packages/cli/scripts/prepare-publish-manifest.mjs @@ -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 "); diff --git a/packages/cli/src/__tests__/plugin-sdk-core-runtime-shim.test.ts b/packages/cli/src/__tests__/plugin-sdk-core-runtime-shim.test.ts index 5677fdd53a..8dbda15951 100644 --- a/packages/cli/src/__tests__/plugin-sdk-core-runtime-shim.test.ts +++ b/packages/cli/src/__tests__/plugin-sdk-core-runtime-shim.test.ts @@ -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); diff --git a/packages/cli/src/__tests__/plugin-sdk-export.test.ts b/packages/cli/src/__tests__/plugin-sdk-export.test.ts index b6bc830a1a..d9af330189 100644 --- a/packages/cli/src/__tests__/plugin-sdk-export.test.ts +++ b/packages/cli/src/__tests__/plugin-sdk-export.test.ts @@ -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([]); }); }); diff --git a/packages/cli/src/plugin-sdk-core-runtime-shim.mjs b/packages/cli/src/plugin-sdk-core-runtime-shim.mjs index e069974c5f..d8cb9afa4c 100644 --- a/packages/cli/src/plugin-sdk-core-runtime-shim.mjs +++ b/packages/cli/src/plugin-sdk-core-runtime-shim.mjs @@ -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"; diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 7ead9c7bf7..7a93f3b0b4 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -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 }); } diff --git a/scripts/__tests__/release-package-contract.test.mjs b/scripts/__tests__/release-package-contract.test.mjs new file mode 100644 index 0000000000..f85b6f357a --- /dev/null +++ b/scripts/__tests__/release-package-contract.test.mjs @@ -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\)/); +}); diff --git a/scripts/release.mjs b/scripts/release.mjs index a47abc1834..62c176e3fb 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -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, "──────────────────────────────────────────────")); -