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 */
|
/* 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>");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
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",
|
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, "──────────────────────────────────────────────"));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user