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:
7
.changeset/fn-7913-plugin-publish-dry-run.md
Normal file
7
.changeset/fn-7913-plugin-publish-dry-run.md
Normal 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.
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
197
packages/cli/src/__tests__/plugin-publish.test.ts
Normal file
197
packages/cli/src/__tests__/plugin-publish.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
272
packages/cli/src/commands/plugin-publish.ts
Normal file
272
packages/cli/src/commands/plugin-publish.ts
Normal 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");
|
||||
}
|
||||
Reference in New Issue
Block a user