fix: restore plugin SDK and Todo packaging

This commit is contained in:
gsxdsm
2026-08-03 12:01:42 -07:00
parent 78543233aa
commit 56819e21e9
8 changed files with 196 additions and 30 deletions

View 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.

View File

@@ -1,7 +1,23 @@
/* global process, URL, console */ /* global process, URL, console */
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs"; import { existsSync, readFileSync, statSync, writeFileSync, unlinkSync } from "node:fs";
import { pathToFileURL } from "node:url"; 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) { export function applyPrepackTransform(pkg) {
const devDependencies = { ...(pkg.devDependencies || {}) }; const devDependencies = { ...(pkg.devDependencies || {}) };
@@ -42,28 +58,29 @@ function run() {
const backupPath = new URL("../package.json.pack-backup", import.meta.url); const backupPath = new URL("../package.json.pack-backup", import.meta.url);
if (mode === "prepack") { if (mode === "prepack") {
if (existsSync(backupPath)) { if (existsSync(backupPath)) {
unlinkSync(backupPath); unlinkSync(backupPath);
} }
assertPluginSdkDeclarationExists(dirname(fileURLToPath(packageJsonPath)));
const original = readFileSync(packageJsonPath, "utf8"); const original = readFileSync(packageJsonPath, "utf8");
writeFileSync(backupPath, original, "utf8"); writeFileSync(backupPath, original, "utf8");
const pkg = JSON.parse(original); const pkg = JSON.parse(original);
const transformed = applyPrepackTransform(pkg); const transformed = applyPrepackTransform(pkg);
writeFileSync(packageJsonPath, `${JSON.stringify(transformed, null, 2)}\n`, "utf8"); writeFileSync(packageJsonPath, `${JSON.stringify(transformed, null, 2)}\n`, "utf8");
process.exit(0);
}
if (mode === "postpack") {
if (!existsSync(backupPath)) {
process.exit(0); process.exit(0);
} }
const backup = readFileSync(backupPath, "utf8"); if (mode === "postpack") {
writeFileSync(packageJsonPath, backup, "utf8"); if (!existsSync(backupPath)) {
unlinkSync(backupPath); process.exit(0);
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>"); console.error("Usage: node ./scripts/prepare-publish-manifest.mjs <prepack|postpack>");

View File

@@ -1,4 +1,9 @@
import { EventEmitter } from "node:events"; 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"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
@@ -9,7 +14,10 @@ vi.mock("node:child_process", () => ({
spawn: mocks.spawn, 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 { class FakeChild extends EventEmitter {
pid = 1234; pid = 1234;
@@ -26,6 +34,32 @@ describe("plugin SDK core runtime shim supervision", () => {
vi.restoreAllMocks(); 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", () => { it("absorbs child spawn errors without throwing", () => {
const child = new FakeChild(); const child = new FakeChild();
mocks.spawn.mockReturnValue(child); mocks.spawn.mockReturnValue(child);

View File

@@ -1,9 +1,20 @@
import { describe, it, expect } from "vitest"; 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 { join } from "node:path";
import ts from "typescript"; import ts from "typescript";
import { definePlugin, validatePluginManifest } from "@fusion/plugin-sdk"; 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, "..", "..", "..", ".."); const workspaceRoot = join(__dirname, "..", "..", "..", "..");
@@ -61,6 +72,28 @@ describe("plugin-sdk export surface", () => {
expect(transformed.pi).toEqual(pkg.pi); 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", () => { it("declares plugin-sdk tsup build entry with dts and fusion inlining", () => {
const tsupPath = join(workspaceRoot, "packages", "cli", "tsup.config.ts"); const tsupPath = join(workspaceRoot, "packages", "cli", "tsup.config.ts");
const tsupRaw = readFileSync(tsupPath, "utf-8"); const tsupRaw = readFileSync(tsupPath, "utf-8");
@@ -124,6 +157,9 @@ describe("plugin-sdk export surface", () => {
return; return;
} }
const built = readFileSync(distPath, "utf-8"); const built = readFileSync(distPath, "utf-8");
expect(built.includes("@fusion/")).toBe(false); const fusionTypeSpecifiers = executableModuleSpecifiers(built).filter((specifier) =>
specifier.startsWith("@fusion/"),
);
expect(fusionTypeSpecifiers).toEqual([]);
}); });
}); });

View File

@@ -3,10 +3,11 @@ import { spawn } from "node:child_process";
/* /*
* FNXC:BundledPlugins 2026-07-15-13:40: * FNXC:BundledPlugins 2026-07-15-13:40:
* Clean CI typechecks the CLI before @fusion/core emits dist, but published * Clean CI typechecks the CLI before @fusion/core emits dist, but published
* bundled plugins need both postgresSchema runtime values and Quality's * bundled plugins import selected core runtime values through this alias
* process-group supervisor. Keep this alias implementation in untyped MJS so * (currently postgresSchema, AgentStore, and Quality's process supervisor).
* tsc stays inside the CLI root while esbuild follows the core source schema * Keep this implementation in untyped MJS so tsc stays inside the CLI root
* and bundles every runtime export without a private @fusion/core dependency. * 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 * as postgresSchema from "../../core/src/postgres/schema/index.js";
import { AgentStore } from "../../core/src/agents/agent-store.js"; import { AgentStore } from "../../core/src/agents/agent-store.js";

View File

@@ -168,7 +168,7 @@ function writeSanitizedCopiedManifest(srcPkgPath: string, destPkgPath: string) {
writeFileSync(destPkgPath, JSON.stringify(destPkg, null, 2)); 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)) { if (existsSync(destDir)) {
rmSync(destDir, { recursive: true, force: true }); rmSync(destDir, { recursive: true, force: true });
} }

View 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\)/);
});

View File

@@ -482,6 +482,7 @@ function runReleaseSmoke() {
name: "fusion-smoke-test", name: "fusion-smoke-test",
version: "0.0.0", version: "0.0.0",
private: true, private: true,
type: "module",
overrides: { "@runfusion/fusion": `file:${fusionTarballPath}` }, overrides: { "@runfusion/fusion": `file:${fusionTarballPath}` },
}, },
null, 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); cleanupSmoke(smokeDir);
} }
@@ -1055,7 +1099,7 @@ if (changelogAfterDistill !== changelogBeforeDistill) {
// --- Build ---------------------------------------------------------------- // --- Build ----------------------------------------------------------------
info("Building all packages…"); info("Building all packages…");
run("pnpm build"); run("pnpm build:full");
// --- Commit --------------------------------------------------------------- // --- Commit ---------------------------------------------------------------
@@ -1188,4 +1232,3 @@ console.log(color(36, `─── Draft post for X (${CHANNEL}, copy-paste) ─
console.log(releaseTweet); console.log(releaseTweet);
console.log(color(90, `(${releaseTweet.length}/280 chars; source: ${distillSource})`)); console.log(color(90, `(${releaseTweet.length}/280 chars; source: ${distillSource})`));
console.log(color(36, "──────────────────────────────────────────────")); console.log(color(36, "──────────────────────────────────────────────"));