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 <path> [--dry-run] [--previous-version <semver>]` 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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-12 21:27:38 -07:00
parent b85a6b8663
commit 3326984a6d
8 changed files with 521 additions and 7 deletions

View File

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

View File

@@ -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
```

View File

@@ -1203,9 +1203,10 @@ fn plugin disable <id>
fn plugin create <name>
fn plugin new <name> [--output <dir>] [--scope <scope>]
fn plugin dev <path> [--once] [--ai-scan]
fn plugin publish <path> [--dry-run] [--previous-version <semver>]
```
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 <path>` 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 <id>` 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 <path>` 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 <semver>`), then prints manual `pnpm build` → `pnpm pack` → `npm publish --access public` next steps without installing, uploading, or tagging anything.
---
## `fn skills`

View File

@@ -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 <semver>`. 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:

View File

@@ -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",
);
});

View File

@@ -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<string, unknown>;
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<string> {
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, unknown> = {}): 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<string> {
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();
});
});

View File

@@ -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 <name> Scaffold a new plugin project
fn plugin new <name> Scaffold a standalone publishable plugin project
fn plugin dev <path> Build, install, and hot-reload a plugin locally
fn plugin publish <path> [--dry-run] [--previous-version <semver>]
Preflight a plugin before manual pack/publish
fn skills search <query> Search skills.sh for agent skills
fn skills search <query> --limit 5 Limit results
fn skills install <owner/repo> 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 <path> [--dry-run] [--previous-version <semver>]"); 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;

View File

@@ -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<string, unknown> | undefined {
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
}
async function readPackageJsonVersion(pluginDir: string): Promise<string | undefined> {
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<string[]> {
try {
const imported = await import(pathToFileURL(entryPath).href);
const plugin = imported.default as Partial<FusionPlugin> | 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<PluginPreflightReport> {
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<void> {
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 <semver>)");
}
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");
}