From 3326984a6d1550f18049c9e3ff6cd264d98da4e4 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 12 Jul 2026 21:27:38 -0700 Subject: [PATCH] FN-7913: add fn plugin publish --dry-run preflight command Adds a non-mutating `fn plugin publish` CLI command that preflights a plugin before manual pack/publish, giving external plugin authors an offline readiness check. - New `packages/cli/src/commands/plugin-publish.ts` with `runPluginPublish`, `collectPluginPreflight`, and `classifyVersionBump` (strict x.y.z semver bump classification), reusing `loadManifestFromPath` / `resolvePluginEntryFile` from the install path - Wire `fn plugin publish [--dry-run] [--previous-version ]` into `bin.ts` command routing, dynamic import list, and help text - Add test coverage in `plugin-publish.test.ts` and update `bin.test.ts` for the new subcommand - Update `docs/PLUGIN_AUTHORING.md`, `docs/cli-reference.md`, and `docs/plugins/external-authoring.md` to document the new preflight command - Add changeset `.changeset/fn-7913-plugin-publish-dry-run.md` (minor, @runfusion/fusion) Files changed: .changeset/fn-7913-plugin-publish-dry-run.md | 7 + docs/PLUGIN_AUTHORING.md | 9 +- docs/cli-reference.md | 5 +- docs/plugins/external-authoring.md | 14 +- packages/cli/src/__tests__/bin.test.ts | 2 +- packages/cli/src/__tests__/plugin-publish.test.ts | 197 ++++++++++++++++ packages/cli/src/bin.ts | 22 +- packages/cli/src/commands/plugin-publish.ts | 272 ++++++++++++++++++++++ 8 files changed, 521 insertions(+), 7 deletions(-) Fusion-Task-Id: FN-7913 Fusion-Task-Lineage: 27bdf937-3195-4619-9d01-b6af4fbba487 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7913-plugin-publish-dry-run.md | 7 + docs/PLUGIN_AUTHORING.md | 9 +- docs/cli-reference.md | 5 +- docs/plugins/external-authoring.md | 14 +- packages/cli/src/__tests__/bin.test.ts | 2 +- .../cli/src/__tests__/plugin-publish.test.ts | 197 +++++++++++++ packages/cli/src/bin.ts | 22 +- packages/cli/src/commands/plugin-publish.ts | 272 ++++++++++++++++++ 8 files changed, 521 insertions(+), 7 deletions(-) create mode 100644 .changeset/fn-7913-plugin-publish-dry-run.md create mode 100644 packages/cli/src/__tests__/plugin-publish.test.ts create mode 100644 packages/cli/src/commands/plugin-publish.ts diff --git a/.changeset/fn-7913-plugin-publish-dry-run.md b/.changeset/fn-7913-plugin-publish-dry-run.md new file mode 100644 index 0000000000..c780634d20 --- /dev/null +++ b/.changeset/fn-7913-plugin-publish-dry-run.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add `fn plugin publish --dry-run` preflight that validates a plugin before publishing. +category: feature +dev: New `runPluginPublish`/`collectPluginPreflight`/`classifyVersionBump` in packages/cli/src/commands/plugin-publish.ts; reuses loadManifestFromPath + resolvePluginEntryFile. Non-mutating; no registry/network calls. diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index ec3721fffb..4e2764d33b 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -1211,7 +1211,7 @@ This keeps regressions durable while preserving clear ownership boundaries acros ## 13. Publishing Plugins -For end-to-end standalone packaging, `pnpm pack`, and installing on another machine, follow the [External Plugin Authoring guide](./plugins/external-authoring.md). +For end-to-end standalone packaging, `pnpm pack`, and installing on another machine, follow the [External Plugin Authoring guide](./plugins/external-authoring.md). Run `fn plugin publish --dry-run .` before packing to validate the manifest, compiled entrypoint, lifecycle hook shape, and optional version bump without installing, uploading, or tagging anything. ### Package Requirements @@ -1246,7 +1246,12 @@ For end-to-end standalone packaging, `pnpm pack`, and installing on another mach pnpm build ``` -3. Publish to npm: +3. Run the non-mutating publish preflight: + ```bash + fn plugin publish --dry-run . --previous-version 0.9.0 + ``` + +4. Publish to npm: ```bash npm publish --access public ``` diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 00e6e20c3d..4d4b2c5828 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1203,9 +1203,10 @@ fn plugin disable fn plugin create fn plugin new [--output ] [--scope ] fn plugin dev [--once] [--ai-scan] +fn plugin publish [--dry-run] [--previous-version ] ``` -Subcommands: `list|ls`, `install`, `rescan`, `trust`, `untrust`, `verify`, `uninstall`, `enable`, `disable`, `create`, `new`, `dev`. +Subcommands: `list|ls`, `install`, `rescan`, `trust`, `untrust`, `verify`, `uninstall`, `enable`, `disable`, `create`, `new`, `dev`, `publish`. Scope semantics: - `fn plugin install ` accepts a built plugin directory or installed package name, not a packed `.tgz` tarball; extract tarballs before installing. @@ -1215,6 +1216,8 @@ Scope semantics: `fn plugin install --ai-scan` enables AI security scanning on plugin load. `fn plugin rescan ` runs a fresh scan/reload cycle and prints plugin name, verdict, summary, and finding count. It exits non-zero for `blocked`, `error`, or `unavailable` verdicts. +`fn plugin publish --dry-run ` runs an offline, non-mutating publish preflight for external authors. It validates `manifest.json`, the compiled JavaScript entrypoint, declared lifecycle hooks, and the optional version bump (`--previous-version `), then prints manual `pnpm build` → `pnpm pack` → `npm publish --access public` next steps without installing, uploading, or tagging anything. + --- ## `fn skills` diff --git a/docs/plugins/external-authoring.md b/docs/plugins/external-authoring.md index fe0ccea5ef..bfb2ac719a 100644 --- a/docs/plugins/external-authoring.md +++ b/docs/plugins/external-authoring.md @@ -60,7 +60,17 @@ If you prefer npm for a scaffold that supports it: npm test ``` -## 4. Package +## 4. Preflight publish readiness + +Before packing, run the offline publish preflight against the built plugin directory: + +```bash +fn plugin publish --dry-run . +``` + +The preflight validates `manifest.json`, rejects missing builds or `.ts` source entrypoints by resolving the compiled JavaScript entry the same way `fn plugin install` does, verifies the default-exported plugin manifest and declared lifecycle hooks, and reports the version bump class when you pass `--previous-version `. It does not install, upload, publish, tag, or contact a registry. + +## 5. Package Build first, then create an npm tarball: @@ -94,7 +104,7 @@ Before sharing the tarball, confirm the artifact does not include private monore - no `workspace:*` dependency ranges - SDK imports come from `@runfusion/fusion/plugin-sdk` -## 5. Install elsewhere +## 6. Install elsewhere On another machine with Fusion installed, extract or install the tarball, then point Fusion at the extracted plugin directory: diff --git a/packages/cli/src/__tests__/bin.test.ts b/packages/cli/src/__tests__/bin.test.ts index f36b15b883..4675502cca 100644 --- a/packages/cli/src/__tests__/bin.test.ts +++ b/packages/cli/src/__tests__/bin.test.ts @@ -599,7 +599,7 @@ describe("bin command routing and fallbacks", () => { await expect(runBin(["plugin", "oops"])).rejects.toThrow("process.exit:1"); expect(errorSpy).toHaveBeenCalledWith("Unknown subcommand: plugin oops"); expect(logSpy).toHaveBeenCalledWith( - "Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | available | settings | rescan | setup-status | setup | create | new | dev", + "Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | available | settings | rescan | setup-status | setup | create | new | dev | publish", ); }); diff --git a/packages/cli/src/__tests__/plugin-publish.test.ts b/packages/cli/src/__tests__/plugin-publish.test.ts new file mode 100644 index 0000000000..58f8f22e12 --- /dev/null +++ b/packages/cli/src/__tests__/plugin-publish.test.ts @@ -0,0 +1,197 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + registerPlugin: vi.fn(), +})); + +vi.mock("@fusion/core", () => ({ + PluginStore: vi.fn().mockImplementation(() => ({ + init: vi.fn().mockResolvedValue(undefined), + registerPlugin: mocks.registerPlugin, + })), + PluginLoader: vi.fn(), + resolveGlobalDir: vi.fn().mockReturnValue("/tmp/fusion-global"), + validatePluginManifest: vi.fn((manifest: unknown) => { + const errors: string[] = []; + if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) { + return { valid: false, errors: ["Manifest must be an object"] }; + } + const candidate = manifest as Record; + if (!candidate.id || typeof candidate.id !== "string") errors.push("id is required"); + if (!candidate.name || typeof candidate.name !== "string") errors.push("name is required"); + if (typeof candidate.version !== "string" || !/^\d+\.\d+\.\d+$/.test(candidate.version)) { + errors.push("version must be a valid semver string (e.g., 1.0.0)"); + } + return { valid: errors.length === 0, errors }; + }), +})); + +import { + classifyVersionBump, + collectPluginPreflight, + runPluginPublish, +} from "../commands/plugin-publish.js"; + +async function writeFixture(files: Array<{ path: string; content: string }>): Promise { + const dir = await mkdtemp(join(tmpdir(), "fn-plugin-publish-test-")); + for (const file of files) { + const path = join(dir, file.path); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, file.content, "utf-8"); + } + return dir; +} + +function manifest(version = "1.2.3", extra: Record = {}): string { + return JSON.stringify({ id: "publish-test", name: "Publish Test", version, ...extra }, null, 2); +} + +function packageJson(version = "1.2.3", main = "./dist/index.js"): string { + return JSON.stringify({ name: "fusion-plugin-publish-test", version, type: "module", main }, null, 2); +} + +function pluginModule(version = "1.2.3", hooks = "onLoad() {}, onUnload() {}"): string { + return `export default {\n manifest: { id: "publish-test", name: "Publish Test", version: "${version}" },\n state: "installed",\n hooks: { ${hooks} }\n};\n`; +} + +async function validFixture(version = "1.2.3"): Promise { + return writeFixture([ + { path: "manifest.json", content: manifest(version) }, + { path: "package.json", content: packageJson(version) }, + { path: "dist/index.js", content: pluginModule(version) }, + ]); +} + +describe("plugin publish preflight", () => { + const tempDirs: string[] = []; + + beforeEach(() => { + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + mocks.registerPlugin.mockReset(); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))); + tempDirs.length = 0; + }); + + it("classifies strict semver bumps", () => { + expect(classifyVersionBump("1.2.3", "2.0.0")).toBe("major"); + expect(classifyVersionBump("1.2.3", "1.3.0")).toBe("minor"); + expect(classifyVersionBump("1.2.3", "1.2.4")).toBe("patch"); + expect(classifyVersionBump("1.2.3", "1.2.3")).toBe("none"); + expect(classifyVersionBump("1.2", "1.2.3")).toBe("invalid"); + expect(classifyVersionBump("1.2.3", "1.2.3-beta.1")).toBe("invalid"); + expect(classifyVersionBump("2.0.0", "1.9.9")).toBe("invalid"); + }); + + it("collects a happy-path preflight report without mutating plugin state", async () => { + const dir = await validFixture(); + tempDirs.push(dir); + + const report = await collectPluginPreflight(dir, { previousVersion: "1.2.2" }); + + expect(report.ok).toBe(true); + expect(report.manifest).toMatchObject({ id: "publish-test", version: "1.2.3" }); + expect(report.entryPath).toBe(join(dir, "dist", "index.js")); + expect(report.declaredHooks).toEqual(["hooks.onLoad", "hooks.onUnload"]); + expect(report.versionBump).toEqual({ class: "patch", previous: "1.2.2", next: "1.2.3" }); + expect(mocks.registerPlugin).not.toHaveBeenCalled(); + }); + + it("reports manifest validation failures", async () => { + const dir = await writeFixture([ + { path: "manifest.json", content: JSON.stringify({ name: "No ID", version: "not-semver" }) }, + { path: "package.json", content: packageJson() }, + { path: "dist/index.js", content: pluginModule() }, + ]); + tempDirs.push(dir); + + const report = await collectPluginPreflight(dir); + + expect(report.ok).toBe(false); + expect(report.checks).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "Manifest", status: "fail" }), + ])); + }); + + it("reports TypeScript source entrypoints as missing builds", async () => { + const dir = await writeFixture([ + { path: "manifest.json", content: manifest() }, + { path: "package.json", content: packageJson("1.2.3", "./src/index.ts") }, + { path: "src/index.ts", content: "export default {};\n" }, + ]); + tempDirs.push(dir); + + const report = await collectPluginPreflight(dir); + + expect(report.ok).toBe(false); + expect(report.checks).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "Entrypoint", status: "fail", detail: expect.stringContaining("Build the plugin first") }), + ])); + }); + + it("fails when package.json and manifest.json versions differ", async () => { + const dir = await writeFixture([ + { path: "manifest.json", content: manifest("1.2.3") }, + { path: "package.json", content: packageJson("1.2.4") }, + { path: "dist/index.js", content: pluginModule("1.2.3") }, + ]); + tempDirs.push(dir); + + const report = await collectPluginPreflight(dir); + + expect(report.ok).toBe(false); + expect(report.checks).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "Package version", status: "fail" }), + ])); + }); + + it("warns without previous version and fails downgrade classification", async () => { + const dir = await validFixture(); + tempDirs.push(dir); + + const withoutPrevious = await collectPluginPreflight(dir); + expect(withoutPrevious.versionBump).toBeNull(); + expect(withoutPrevious.checks).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "Version bump", status: "warn" }), + ])); + + const downgrade = await collectPluginPreflight(dir, { previousVersion: "2.0.0" }); + expect(downgrade.ok).toBe(false); + expect(downgrade.versionBump).toEqual({ class: "invalid", previous: "2.0.0", next: "1.2.3" }); + }); + + it("exits non-zero on failed command preflight without uncaught plugin-registration work", async () => { + const dir = await writeFixture([ + { path: "manifest.json", content: manifest() }, + { path: "package.json", content: packageJson("9.9.9") }, + { path: "dist/index.js", content: pluginModule() }, + ]); + tempDirs.push(dir); + vi.spyOn(process, "exit").mockImplementation(((code?: string | number | null) => { + throw new Error(`exit:${code}`); + }) as never); + + await expect(runPluginPublish(dir, { dryRun: true })).rejects.toThrow("exit:1"); + expect(mocks.registerPlugin).not.toHaveBeenCalled(); + }); + + it("prints manual pack and publish next steps on success", async () => { + const dir = await validFixture(); + tempDirs.push(dir); + const log = vi.mocked(console.log); + + await runPluginPublish(dir, { dryRun: true, previousVersion: "1.2.2" }); + + expect(log).toHaveBeenCalledWith(expect.stringContaining("preflight passed")); + expect(log).toHaveBeenCalledWith(" pnpm pack"); + expect(log).toHaveBeenCalledWith(" npm publish --access public"); + expect(mocks.registerPlugin).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index d1fa6a95e0..8fdfbc4338 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -145,6 +145,7 @@ async function loadCommandHandlers() { const { runPluginList, runPluginInstall, runPluginUninstall, runPluginEnable, runPluginDisable, runPluginSetupStatus, runPluginSetup, runPluginAvailable, runPluginSettings, runPluginRescan } = await import("./commands/plugin.js"); const { runPluginCreate, runPluginNew } = await import("./commands/plugin-scaffold.js"); const { runPluginDev } = await import("./commands/plugin-dev.js"); + const { runPluginPublish } = await import("./commands/plugin-publish.js"); const { runSkillsSearch, runSkillsInstall } = await import("./commands/skills.js"); const { runResearchCreate, runResearchList, runResearchShow, runResearchExport, runResearchCancel, runResearchRetry } = await import("./commands/research.js"); const { runExperimentFinalize } = await import("./commands/experiment-finalize.js"); @@ -269,6 +270,7 @@ async function loadCommandHandlers() { runPluginCreate, runPluginNew, runPluginDev, + runPluginPublish, runSkillsSearch, runSkillsInstall, runResearchCreate, @@ -462,6 +464,8 @@ PR: fn plugin create Scaffold a new plugin project fn plugin new Scaffold a standalone publishable plugin project fn plugin dev Build, install, and hot-reload a plugin locally + fn plugin publish [--dry-run] [--previous-version ] + Preflight a plugin before manual pack/publish fn skills search Search skills.sh for agent skills fn skills search --limit 5 Limit results fn skills install Install skills from a source @@ -780,6 +784,7 @@ async function main() { runPluginCreate, runPluginNew, runPluginDev, + runPluginPublish, runSkillsSearch, runSkillsInstall, runResearchCreate, @@ -2145,9 +2150,24 @@ async function main() { }); break; } + case "publish": { + const publishArgs = args.slice(2); + const previousVersion = getFlagValue(publishArgs, "--previous-version"); + const pluginPath = publishArgs.find((value, index) => { + if (value.startsWith("--")) return false; + return !(publishArgs[index - 1] === "--previous-version"); + }); + if (!pluginPath) { console.error("Usage: fn plugin publish [--dry-run] [--previous-version ]"); process.exit(1); } + await runPluginPublish(pluginPath, { + dryRun: args.includes("--dry-run"), + previousVersion, + projectName, + }); + break; + } default: console.error(`Unknown subcommand: plugin ${sub || ""}`); - console.log("Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | available | settings | rescan | setup-status | setup | create | new | dev"); + console.log("Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | available | settings | rescan | setup-status | setup | create | new | dev | publish"); process.exit(1); } break; diff --git a/packages/cli/src/commands/plugin-publish.ts b/packages/cli/src/commands/plugin-publish.ts new file mode 100644 index 0000000000..323c9573e3 --- /dev/null +++ b/packages/cli/src/commands/plugin-publish.ts @@ -0,0 +1,272 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import type { FusionPlugin, PluginManifest } from "@fusion/core"; +import { loadManifestFromPath, resolvePluginEntryFile } from "./plugin.js"; + +export type VersionBumpClass = "major" | "minor" | "patch" | "none" | "invalid"; + +export interface PluginPreflightCheck { + name: string; + status: "pass" | "fail" | "warn"; + detail: string; +} + +export interface PluginPreflightReport { + ok: boolean; + manifest?: PluginManifest; + entryPath?: string; + declaredHooks: string[]; + versionBump: { class: VersionBumpClass; previous?: string; next: string } | null; + checks: PluginPreflightCheck[]; +} + +interface PackageJsonWithVersion { + version?: unknown; +} + +const STRICT_SEMVER = /^(\d+)\.(\d+)\.(\d+)$/; + +/* + * FNXC:PluginPublish 2026-07-12-00:00: + * Plugin publish preflight is offline and non-mutating for external author readiness (G-MPS8FPMK-0001-SAWD). It reuses the install path's manifest and compiled-entrypoint checks so first-publish failures surface before packing without registry, tag, install, or lifecycle side effects. + * + * FNXC:PluginPublish 2026-07-12-00:00: + * The bump classifier intentionally accepts only strict numeric x.y.z versions, matching validatePluginManifest. Downgrades are invalid because a publish preflight should not bless a version that cannot represent a forward release. + */ +export function classifyVersionBump(previous: string, next: string): VersionBumpClass { + const previousMatch = STRICT_SEMVER.exec(previous); + const nextMatch = STRICT_SEMVER.exec(next); + if (!previousMatch || !nextMatch) { + return "invalid"; + } + + const previousParts = previousMatch.slice(1).map(Number) as [number, number, number]; + const nextParts = nextMatch.slice(1).map(Number) as [number, number, number]; + + if (nextParts[0] < previousParts[0]) return "invalid"; + if (nextParts[0] > previousParts[0]) return "major"; + + if (nextParts[1] < previousParts[1]) return "invalid"; + if (nextParts[1] > previousParts[1]) return "minor"; + + if (nextParts[2] < previousParts[2]) return "invalid"; + if (nextParts[2] > previousParts[2]) return "patch"; + + return "none"; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" ? (value as Record) : undefined; +} + +async function readPackageJsonVersion(pluginDir: string): Promise { + const packageJsonPath = join(pluginDir, "package.json"); + const packageJson = JSON.parse(await readFile(packageJsonPath, "utf-8")) as PackageJsonWithVersion; + return typeof packageJson.version === "string" ? packageJson.version : undefined; +} + +function collectDeclaredFunctionHooks( + value: unknown, + prefix: string, + checks: PluginPreflightCheck[], +): string[] { + const hooksRecord = asRecord(value); + if (!hooksRecord) return []; + + const declaredHooks: string[] = []; + for (const [name, hook] of Object.entries(hooksRecord)) { + if (hook === undefined) continue; + const hookName = `${prefix}.${name}`; + declaredHooks.push(hookName); + if (typeof hook !== "function") { + checks.push({ + name: "Lifecycle hooks", + status: "fail", + detail: `${hookName} must be a function.`, + }); + } + } + return declaredHooks; +} + +async function validateModuleShape( + entryPath: string, + manifest: PluginManifest, + checks: PluginPreflightCheck[], +): Promise { + try { + const imported = await import(pathToFileURL(entryPath).href); + const plugin = imported.default as Partial | undefined; + const pluginRecord = asRecord(plugin); + if (!pluginRecord) { + checks.push({ name: "Plugin module", status: "fail", detail: "Default export must be a plugin object." }); + return []; + } + + const moduleManifest = asRecord(pluginRecord.manifest); + if (!moduleManifest) { + checks.push({ name: "Plugin module", status: "fail", detail: "Default export must include manifest." }); + } else if (moduleManifest.id !== manifest.id || moduleManifest.version !== manifest.version) { + checks.push({ + name: "Plugin module", + status: "fail", + detail: `Default export manifest id/version must match manifest.json (${manifest.id}@${manifest.version}).`, + }); + } else { + checks.push({ name: "Plugin module", status: "pass", detail: "Default export manifest matches manifest.json." }); + } + + const declaredHooks = [ + ...collectDeclaredFunctionHooks(pluginRecord.hooks, "hooks", checks), + ...collectDeclaredFunctionHooks(asRecord(pluginRecord.setup)?.hooks, "setup.hooks", checks), + ].sort(); + + if (declaredHooks.length === 0) { + checks.push({ name: "Lifecycle hooks", status: "warn", detail: "No lifecycle hooks declared." }); + } else if (!checks.some((check) => check.name === "Lifecycle hooks" && check.status === "fail")) { + checks.push({ + name: "Lifecycle hooks", + status: "pass", + detail: `Declared hook functions: ${declaredHooks.join(", ")}.`, + }); + } + + return declaredHooks; + } catch (error) { + checks.push({ name: "Plugin module", status: "fail", detail: errorMessage(error) }); + return []; + } +} + +export async function collectPluginPreflight( + pluginDir: string, + options?: { previousVersion?: string }, +): Promise { + const checks: PluginPreflightCheck[] = []; + let manifest: PluginManifest | undefined; + let entryPath: string | undefined; + let declaredHooks: string[] = []; + let versionBump: PluginPreflightReport["versionBump"] = null; + const absolutePluginDir = resolve(pluginDir); + + try { + const loaded = await loadManifestFromPath(absolutePluginDir); + manifest = loaded.manifest; + checks.push({ name: "Manifest", status: "pass", detail: `manifest.json is valid for ${manifest.id}@${manifest.version}.` }); + } catch (error) { + checks.push({ name: "Manifest", status: "fail", detail: errorMessage(error) }); + } + + try { + entryPath = await resolvePluginEntryFile(absolutePluginDir); + checks.push({ name: "Entrypoint", status: "pass", detail: `Resolved compiled entrypoint: ${entryPath}.` }); + } catch (error) { + checks.push({ name: "Entrypoint", status: "fail", detail: errorMessage(error) }); + } + + if (manifest) { + try { + const packageVersion = await readPackageJsonVersion(absolutePluginDir); + if (packageVersion === manifest.version) { + checks.push({ name: "Package version", status: "pass", detail: `package.json version matches manifest.json (${manifest.version}).` }); + } else { + checks.push({ + name: "Package version", + status: "fail", + detail: `package.json version (${packageVersion ?? "missing"}) must match manifest.json version (${manifest.version}).`, + }); + } + } catch (error) { + checks.push({ name: "Package version", status: "fail", detail: errorMessage(error) }); + } + } + + if (manifest && entryPath) { + declaredHooks = await validateModuleShape(entryPath, manifest, checks); + } + + if (manifest) { + if (options?.previousVersion) { + const bumpClass = classifyVersionBump(options.previousVersion, manifest.version); + versionBump = { class: bumpClass, previous: options.previousVersion, next: manifest.version }; + checks.push({ + name: "Version bump", + status: bumpClass === "invalid" ? "fail" : "pass", + detail: bumpClass === "invalid" + ? `Cannot classify ${options.previousVersion} → ${manifest.version}; use strict semver and do not downgrade.` + : `Classified ${options.previousVersion} → ${manifest.version} as ${bumpClass}.`, + }); + } else { + versionBump = null; + checks.push({ + name: "Version bump", + status: "warn", + detail: "Pass --previous-version to classify the bump.", + }); + } + } + + return { + ok: !checks.some((check) => check.status === "fail"), + manifest, + entryPath, + declaredHooks, + versionBump, + checks, + }; +} + +function statusIcon(status: PluginPreflightCheck["status"]): string { + if (status === "pass") return "✓"; + if (status === "warn") return "⚠"; + return "✗"; +} + +export async function runPluginPublish( + source: string, + options?: { dryRun?: boolean; previousVersion?: string; projectName?: string }, +): Promise { + if (!existsSync(source)) { + console.error(`Plugin path does not exist: ${source}`); + process.exit(1); + } + + const pluginDir = resolve(source); + const report = await collectPluginPreflight(pluginDir, { previousVersion: options?.previousVersion }); + + console.log("Plugin publish preflight"); + console.log(` Path: ${pluginDir}`); + console.log(` Mode: ${options?.dryRun ? "dry-run" : "preflight-only"}`); + for (const check of report.checks) { + console.log(` ${statusIcon(check.status)} ${check.name.padEnd(16)} ${check.detail}`); + } + if (report.entryPath) { + console.log(` Entry: ${report.entryPath}`); + } + console.log(` Hooks: ${report.declaredHooks.length > 0 ? report.declaredHooks.join(", ") : "none declared"}`); + if (report.versionBump) { + console.log(` Version bump: ${report.versionBump.previous} → ${report.versionBump.next} (${report.versionBump.class})`); + } else if (report.manifest) { + console.log(" Version bump: not classified (pass --previous-version )"); + } + + if (!report.ok) { + console.error("Plugin publish preflight failed. Fix the failing checks before packing or publishing."); + process.exit(1); + } + + console.log("Plugin publish preflight passed. Fusion did not install, upload, publish, or tag anything."); + if (!options?.dryRun) { + console.log("Fusion does not upload or tag plugins on your behalf; run the manual publish steps yourself when ready."); + } + console.log("Next steps:"); + console.log(" pnpm build"); + console.log(" pnpm pack"); + console.log(" npm publish --access public"); +}