release: WRNexusJS 0.8.0
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-02 23:18:51 +05:30
parent 87507edf59
commit 586a6db8ff
625 changed files with 243608 additions and 11210 deletions
+53
View File
@@ -1,7 +1,60 @@
# @wrnexus/plugin
## Least-privilege package permissions
Package manifests declare every framework capability they register:
```json
{
"wrnexus": {
"permissions": ["routes", "migrations"],
"routes": [{ "kind": "api", "path": "/api/example", "entry": "./route.ts" }]
}
}
```
Applications can enable fail-closed grants:
```ts
export default {
pluginPermissions: {
enforce: true,
grants: { "example-plugin": ["routes"] },
},
};
```
Discovery rejects used-but-undeclared capabilities with
`WRN-PLUGIN-PERMISSION-UNDECLARED` and ungranted capabilities with
`WRN-PLUGIN-PERMISSION-DENIED`. Permissions cover components, browser runtime,
assets, styles, routes, middleware, migrations, config, transforms,
diagnostics/tooling, and server/build hooks.
## Compatibility matrices
Manifests can add `compatibility: { bunMin: "1.3.0", os: ["linux",
"darwin"] }` alongside `runtimes` and `requires`. Use
`testPluginCompatibility(manifest, targets)` in a package test to exercise the
complete support matrix. Runtime discovery enforces the same Bun minimum, OS,
runtime, and capability declarations used by the test kit.
Deterministic WRNexusJS plugin contracts for configuration, AST/code transforms,
diagnostics, development servers, production builds, and DevToolbar extensions.
Use `definePlugin()` and declare `enforce`, `before`, or `after` when ordering matters.
Duplicate names and dependency cycles are rejected.
## Complete lifecycle and contributions
Plugins may implement `setup`, `configure`, `configResolved`, `transformAst`,
`transformCode`, `diagnostics`, `routes`, `configureServer`, `buildStart`,
`buildEnd`, `render`, `deploy`, `shutdown`, and `hmrUpdate`. The runner preserves
resolved plugin order for every hook and executes `setup` exactly once.
In addition to components, routes, middleware, assets, styles, runtimes, and
migrations, plugins can contribute `directives`, `cliCommands`,
`virtualModules`, `deploymentAdapters`, `configSchemas`, `documentation`, and
`typeDefinitions`. Names are collision checked. Configuration schemas run after
configuration resolution, CLI commands are callable as normal `wrnexus`
commands, directives participate in AST transformation, and production builds
materialize virtual modules and invoke matching contributed adapters.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/plugin",
"version": "0.7.0",
"version": "0.8.0",
"type": "module",
"main": "src/index.ts",
"exports": {
+78
View File
@@ -0,0 +1,78 @@
import type { WrnexusPackageManifest } from "./types.ts";
export interface PluginCompatibilityTarget {
runtime: "bun" | "node" | "edge" | "worker" | "service-worker" | "browser";
version?: string;
os?: "win32" | "linux" | "darwin" | string;
capabilities?: readonly string[];
}
export interface PluginCompatibilityResult {
target: PluginCompatibilityTarget;
ok: boolean;
issues: Array<{
code:
| "WRN-PLUGIN-MATRIX-RUNTIME"
| "WRN-PLUGIN-MATRIX-VERSION"
| "WRN-PLUGIN-MATRIX-OS"
| "WRN-PLUGIN-MATRIX-CAPABILITY";
message: string;
}>;
}
function parts(version: string): number[] {
return version
.replace(/^[^\d]*/, "")
.split(/[.-]/)
.slice(0, 3)
.map((value) => Number(value) || 0);
}
function atLeast(version: string, minimum: string): boolean {
const left = parts(version);
const right = parts(minimum);
for (let index = 0; index < 3; index += 1) {
if (left[index] !== right[index]) return (left[index] ?? 0) > (right[index] ?? 0);
}
return true;
}
export function testPluginCompatibility(
manifest: WrnexusPackageManifest,
targets: readonly PluginCompatibilityTarget[],
): PluginCompatibilityResult[] {
return targets.map((target) => {
const issues: PluginCompatibilityResult["issues"] = [];
if (manifest.runtimes?.length && !manifest.runtimes.includes(target.runtime)) {
issues.push({
code: "WRN-PLUGIN-MATRIX-RUNTIME",
message: `${target.runtime} is not declared.`,
});
}
const compatibility = manifest.compatibility;
if (
target.runtime === "bun" &&
target.version &&
compatibility?.bunMin &&
!atLeast(target.version, compatibility.bunMin)
) {
issues.push({
code: "WRN-PLUGIN-MATRIX-VERSION",
message: `Bun ${target.version} is below ${compatibility.bunMin}.`,
});
}
if (target.os && compatibility?.os?.length && !compatibility.os.includes(target.os)) {
issues.push({ code: "WRN-PLUGIN-MATRIX-OS", message: `${target.os} is not declared.` });
}
const missing = (manifest.requires ?? []).filter(
(capability) => !target.capabilities?.includes(capability),
);
if (missing.length) {
issues.push({
code: "WRN-PLUGIN-MATRIX-CAPABILITY",
message: `Missing: ${missing.join(", ")}.`,
});
}
return { target: { ...target }, ok: issues.length === 0, issues };
});
}
+101
View File
@@ -12,8 +12,10 @@ import type {
PackageStyleDefinition,
PackageRouteDefinition,
PackageMigrationDefinition,
PluginPermission,
} from "./types.ts";
import { definePlugin, flattenPlugins } from "./base.ts";
import { testPluginCompatibility } from "./compatibility.ts";
interface PackageJson {
name?: string;
@@ -32,6 +34,70 @@ export interface DiscoverPluginOptions {
strict?: boolean;
includePeerDependencies?: boolean;
warn?: (message: string) => void;
runtime?: string;
capabilities?: readonly string[];
runtimeVersion?: string;
os?: string;
/** When true, every requested capability must be declared and granted by the application. */
enforcePermissions?: boolean;
grantedPermissions?: Readonly<Record<string, readonly PluginPermission[]>>;
}
function manifestPermissions(manifest: WrnexusPackageManifest): PluginPermission[] {
const permissions: PluginPermission[] = [];
if (manifest.components?.length) permissions.push("components");
if (manifest.clientRuntimes?.length) permissions.push("client-runtime");
if (manifest.assets?.length) permissions.push("assets");
if (manifest.styles?.length) permissions.push("styles");
if (manifest.routes?.length) permissions.push("routes");
if (manifest.middleware?.length) permissions.push("middleware");
if (manifest.migrations?.length) permissions.push("migrations");
return permissions;
}
function modulePermissions(plugin: WrnexusPlugin): PluginPermission[] {
const permissions: PluginPermission[] = [];
if (plugin.componentDirs) permissions.push("components");
if (plugin.clientRuntimes) permissions.push("client-runtime");
if (plugin.assets) permissions.push("assets");
if (plugin.styleSources) permissions.push("styles");
if (plugin.routeEntries || plugin.routes) permissions.push("routes");
if (plugin.middleware) permissions.push("middleware");
if (plugin.migrations) permissions.push("migrations");
if (plugin.configure || plugin.configResolved) permissions.push("config");
if (plugin.transformAst || plugin.transformCode) permissions.push("transform");
if (plugin.diagnostics || plugin.devToolbarPanels) permissions.push("diagnostics");
if (plugin.configureServer || plugin.buildStart || plugin.buildEnd) permissions.push("server");
if (plugin.cliCommands) permissions.push("cli");
if (plugin.directives) permissions.push("directives");
if (plugin.virtualModules) permissions.push("virtual-modules");
if (plugin.deploymentAdapters || plugin.deploy) permissions.push("deployment");
if (plugin.documentation) permissions.push("documentation");
if (plugin.typeDefinitions) permissions.push("types");
return [...new Set(permissions)];
}
function enforcePermissions(
packageName: string,
manifest: WrnexusPackageManifest,
required: readonly PluginPermission[],
options: DiscoverPluginOptions,
): void {
if (!options.enforcePermissions) return;
const declared = new Set(manifest.permissions ?? []);
const undeclared = required.filter((permission) => !declared.has(permission));
if (undeclared.length) {
throw new Error(
`WRN-PLUGIN-PERMISSION-UNDECLARED: ${packageName} uses ${undeclared.join(", ")} without declaring them.`,
);
}
const grants = new Set(options.grantedPermissions?.[packageName] ?? []);
const denied = required.filter((permission) => !grants.has(permission));
if (denied.length) {
throw new Error(
`WRN-PLUGIN-PERMISSION-DENIED: ${packageName} is not granted ${denied.join(", ")}.`,
);
}
}
function readJson(path: string): PackageJson | null {
@@ -244,6 +310,40 @@ export async function discoverPlugins(
const packageJson = readJson(join(packageRoot, "package.json"));
const packageManifest = packageJson?.wrnexus;
if (!packageManifest) continue;
enforcePermissions(name, packageManifest, manifestPermissions(packageManifest), options);
if (
options.runtime &&
packageManifest.runtimes?.length &&
!packageManifest.runtimes.includes(options.runtime as never)
) {
throw new Error(`WRN-PLUGIN-RUNTIME: ${name} does not support ${options.runtime}.`);
}
const missingCapabilities = (packageManifest.requires ?? []).filter(
(capability) => !options.capabilities?.includes(capability),
);
if (options.runtime && missingCapabilities.length) {
throw new Error(
`WRN-PLUGIN-CAPABILITY: ${name} requires unavailable capabilities: ${missingCapabilities.join(", ")}.`,
);
}
if (options.runtime) {
const matrix = testPluginCompatibility(packageManifest, [
{
runtime: options.runtime as
"bun" | "node" | "edge" | "worker" | "service-worker" | "browser",
version:
options.runtimeVersion ??
(options.runtime === "bun" && typeof Bun !== "undefined" ? Bun.version : undefined),
os: options.os ?? process.platform,
capabilities: options.capabilities,
},
])[0]!;
const compatibilityIssue = matrix.issues.find((issue) =>
["WRN-PLUGIN-MATRIX-VERSION", "WRN-PLUGIN-MATRIX-OS"].includes(issue.code),
);
if (compatibilityIssue)
throw new Error(`${compatibilityIssue.code}: ${name}: ${compatibilityIssue.message}`);
}
const manifestPlugin = manifestContributionPlugin(
packageRoot,
packageJson?.name ?? name,
@@ -258,6 +358,7 @@ export async function discoverPlugins(
if (manifest.autoDiscover === false) continue;
try {
const plugin = await loadPlugin(packageRoot, manifest);
enforcePermissions(name, packageManifest, modulePermissions(plugin), options);
if (!explicitNames.has(plugin.name) && !automatic.some((item) => item.name === plugin.name)) {
automatic.push(plugin);
}
+88 -3
View File
@@ -1,4 +1,4 @@
import type { PageAst, WrnDiagnostic } from "@wrnexus/syntax";
import type { PageAst, ViewNode, WrnDiagnostic } from "@wrnexus/syntax";
import { normalizeClientRuntime, normalizePackageAsset, validateStyleIds } from "./manifest.ts";
import type {
ClientRuntimeDefinition,
@@ -11,7 +11,13 @@ import type {
PluginContext,
PluginInput,
PluginOrder,
PluginPermission,
PluginRunner,
PluginDirective,
PluginCliCommand,
PluginVirtualModule,
PluginDeploymentAdapter,
PluginConfigSchema,
TransformContext,
WrnexusPlugin,
} from "./types.ts";
@@ -19,6 +25,8 @@ import type {
export * from "./types.ts";
export * from "./manifest.ts";
export { discoverPlugins, type DiscoverPluginOptions } from "./discovery.ts";
export { testPluginCompatibility } from "./compatibility.ts";
export type { PluginCompatibilityResult, PluginCompatibilityTarget } from "./compatibility.ts";
export { definePlugin, flattenPlugins } from "./base.ts";
import { flattenPlugins } from "./base.ts";
@@ -112,9 +120,40 @@ function assertUnique<T>(kind: string, entries: readonly T[], key: (entry: T) =>
}
}
async function transformDirectives(
nodes: ViewNode[],
directives: readonly PluginDirective[],
context: TransformContext,
): Promise<void> {
const registry = new Map(directives.map((directive) => [directive.name, directive]));
for (const node of nodes) {
if (node.type === "element") {
for (const attribute of node.attrs) {
if (!attribute.name.startsWith("use:")) continue;
const name = attribute.name.slice(4);
const directive = registry.get(name);
if (!directive) continue;
const transformed = await directive.transform?.(attribute.value, context);
if (transformed) {
attribute.name = transformed.name;
attribute.value = transformed.value;
} else attribute.name = `data-wrn-directive-${name}`;
}
await transformDirectives(node.children, directives, context);
} else if (node.type === "each") {
await transformDirectives(node.body, directives, context);
await transformDirectives(node.empty, directives, context);
} else if (node.type === "if") {
for (const branch of node.branches)
await transformDirectives(branch.body, directives, context);
}
}
}
export function createPluginRunner(input: PluginInput, context: PluginContext): PluginRunner {
const plugins = resolvePlugins(input);
let contributionCache: PluginContributions | null = null;
let setupComplete = false;
const transformContext = (file: string): TransformContext => ({ ...context, file });
const contributions = async (): Promise<PluginContributions> => {
@@ -126,6 +165,13 @@ export function createPluginRunner(input: PluginInput, context: PluginContext):
const routes: PackageRouteDefinition[] = [];
const middleware: string[] = [];
const migrations: PackageMigrationDefinition[] = [];
const directives: PluginDirective[] = [];
const cliCommands: PluginCliCommand[] = [];
const virtualModules: PluginVirtualModule[] = [];
const deploymentAdapters: PluginDeploymentAdapter[] = [];
const configSchemas: PluginConfigSchema[] = [];
const documentation: string[] = [];
const typeDefinitions: string[] = [];
for (const plugin of plugins) {
componentDirs.push(...(await resolveContribution(plugin.componentDirs, context)));
@@ -135,6 +181,13 @@ export function createPluginRunner(input: PluginInput, context: PluginContext):
routes.push(...(await resolveContribution(plugin.routeEntries, context)));
middleware.push(...(await resolveContribution(plugin.middleware, context)));
migrations.push(...(await resolveContribution(plugin.migrations, context)));
directives.push(...(await resolveContribution(plugin.directives, context)));
cliCommands.push(...(await resolveContribution(plugin.cliCommands, context)));
virtualModules.push(...(await resolveContribution(plugin.virtualModules, context)));
deploymentAdapters.push(...(await resolveContribution(plugin.deploymentAdapters, context)));
configSchemas.push(...(await resolveContribution(plugin.configSchemas, context)));
documentation.push(...(await resolveContribution(plugin.documentation, context)));
typeDefinitions.push(...(await resolveContribution(plugin.typeDefinitions, context)));
}
const normalizedRuntimes = clientRuntimes.map(normalizeClientRuntime);
@@ -164,6 +217,11 @@ export function createPluginRunner(input: PluginInput, context: PluginContext):
}
}
assertUnique("MIGRATION", migrations, (entry) => `${entry.database ?? "default"}:${entry.id}`);
assertUnique("DIRECTIVE", directives, (entry) => entry.name);
assertUnique("CLI-COMMAND", cliCommands, (entry) => entry.name);
assertUnique("VIRTUAL-MODULE", virtualModules, (entry) => entry.id);
assertUnique("DEPLOYMENT-ADAPTER", deploymentAdapters, (entry) => entry.name);
assertUnique("CONFIG-SCHEMA", configSchemas, (entry) => entry.namespace);
contributionCache = {
componentDirs: [...new Set(componentDirs)],
@@ -173,6 +231,13 @@ export function createPluginRunner(input: PluginInput, context: PluginContext):
routes,
middleware: [...new Set(middleware)],
migrations,
directives,
cliCommands,
virtualModules,
deploymentAdapters,
configSchemas,
documentation: [...new Set(documentation)],
typeDefinitions: [...new Set(typeDefinitions)],
};
context.metadata.set("@wrnexus/plugin:contributions", contributionCache);
return contributionCache;
@@ -181,18 +246,29 @@ export function createPluginRunner(input: PluginInput, context: PluginContext):
return {
plugins,
async configure(config) {
if (!setupComplete) {
for (const plugin of plugins) await plugin.setup?.(context);
setupComplete = true;
}
for (const plugin of plugins) await plugin.configure?.(config, context);
contributionCache = null;
},
async configResolved(config) {
for (const plugin of plugins) await plugin.configResolved?.(config, context);
contributionCache = null;
await contributions();
const resolved = await contributions();
for (const schema of resolved.configSchemas)
await schema.validate(config[schema.namespace], context);
},
async transformAst(ast, file) {
let current = ast;
for (const plugin of plugins)
current = (await plugin.transformAst?.(current, transformContext(file))) ?? current;
await transformDirectives(
current.view,
(await contributions()).directives,
transformContext(file),
);
return current;
},
async transformCode(code, file) {
@@ -219,11 +295,19 @@ export function createPluginRunner(input: PluginInput, context: PluginContext):
for (const plugin of plugins) current = (await plugin.routes?.(current, context)) ?? current;
return current;
},
async render(html) {
let current = html;
for (const plugin of plugins) current = (await plugin.render?.(current, context)) ?? current;
return current;
},
async hook(name, value) {
for (const plugin of plugins) {
if (name === "buildStart") await plugin.buildStart?.(context);
else if (name === "buildEnd") await plugin.buildEnd?.(value, context);
else await plugin.configureServer?.(value, context);
else if (name === "configureServer") await plugin.configureServer?.(value, context);
else if (name === "deploy") await plugin.deploy?.(value, context);
else if (name === "shutdown") await plugin.shutdown?.(context);
else await plugin.hmrUpdate?.((value as readonly string[]) ?? [], context);
}
},
};
@@ -237,6 +321,7 @@ export type {
PluginContext,
TransformContext,
WrnexusPlugin,
PluginPermission,
PluginInput,
PluginRunner,
};
+93 -2
View File
@@ -1,10 +1,28 @@
import type { PageAst, WrnDiagnostic } from "@wrnexus/syntax";
export type PluginOrder = "pre" | "normal" | "post";
export type PluginCommand = "dev" | "build" | "test";
export type PluginCommand = "dev" | "build" | "test" | "cli" | "deploy";
export type ClientRuntimeType = "module" | "script";
export type ClientRuntimeLoad = "eager" | "defer" | "idle";
export type ClientRuntimeInject = "head" | "body-end";
export type PluginPermission =
| "components"
| "client-runtime"
| "assets"
| "styles"
| "routes"
| "middleware"
| "migrations"
| "config"
| "transform"
| "diagnostics"
| "server"
| "cli"
| "directives"
| "virtual-modules"
| "deployment"
| "documentation"
| "types";
export interface PluginContext {
root: string;
@@ -100,6 +118,36 @@ export interface PluginDevToolbarPanel {
data?: unknown;
}
export interface PluginCliCommand {
name: string;
description?: string;
run(args: string[], context: PluginContext): void | Promise<void>;
}
export interface PluginVirtualModule {
id: string;
load(context: PluginContext): string | Promise<string>;
}
export interface PluginDirective {
name: string;
transform?: (
value: string,
context: TransformContext,
) => { name: string; value: string } | void | Promise<{ name: string; value: string } | void>;
}
export interface PluginDeploymentAdapter {
name: string;
build(output: unknown, context: PluginContext): unknown | Promise<unknown>;
deploy?(output: unknown, context: PluginContext): unknown | Promise<unknown>;
}
export interface PluginConfigSchema {
namespace: string;
validate(value: unknown, context: PluginContext): void | Promise<void>;
}
export interface PluginContributions {
componentDirs: string[];
clientRuntimes: ClientRuntimeDefinition[];
@@ -108,6 +156,13 @@ export interface PluginContributions {
routes: PackageRouteDefinition[];
middleware: string[];
migrations: PackageMigrationDefinition[];
directives: PluginDirective[];
cliCommands: PluginCliCommand[];
virtualModules: PluginVirtualModule[];
deploymentAdapters: PluginDeploymentAdapter[];
configSchemas: PluginConfigSchema[];
documentation: string[];
typeDefinitions: string[];
}
export interface WrnexusPlugin {
@@ -145,7 +200,25 @@ export interface WrnexusPlugin {
| ((
context: PluginContext,
) => PackageMigrationDefinition[] | Promise<PackageMigrationDefinition[]>);
directives?:
| PluginDirective[]
| ((context: PluginContext) => PluginDirective[] | Promise<PluginDirective[]>);
cliCommands?:
| PluginCliCommand[]
| ((context: PluginContext) => PluginCliCommand[] | Promise<PluginCliCommand[]>);
virtualModules?:
| PluginVirtualModule[]
| ((context: PluginContext) => PluginVirtualModule[] | Promise<PluginVirtualModule[]>);
deploymentAdapters?:
| PluginDeploymentAdapter[]
| ((context: PluginContext) => PluginDeploymentAdapter[] | Promise<PluginDeploymentAdapter[]>);
configSchemas?:
| PluginConfigSchema[]
| ((context: PluginContext) => PluginConfigSchema[] | Promise<PluginConfigSchema[]>);
documentation?: string[] | ((context: PluginContext) => string[] | Promise<string[]>);
typeDefinitions?: string[] | ((context: PluginContext) => string[] | Promise<string[]>);
setup?(context: PluginContext): void | Promise<void>;
configure?(config: Record<string, unknown>, context: PluginContext): void | Promise<void>;
configResolved?(
config: Readonly<Record<string, unknown>>,
@@ -158,6 +231,10 @@ export interface WrnexusPlugin {
configureServer?(server: unknown, context: PluginContext): void | Promise<void>;
buildStart?(context: PluginContext): void | Promise<void>;
buildEnd?(result: unknown, context: PluginContext): void | Promise<void>;
render?(html: string, context: PluginContext): string | void | Promise<string | void>;
deploy?(result: unknown, context: PluginContext): void | Promise<void>;
shutdown?(context: PluginContext): void | Promise<void>;
hmrUpdate?(files: readonly string[], context: PluginContext): void | Promise<void>;
devToolbarPanels?(
context: PluginContext,
): PluginDevToolbarPanel[] | Promise<PluginDevToolbarPanel[]>;
@@ -179,6 +256,16 @@ export interface PackagePluginManifest {
export interface WrnexusPackageManifest {
name?: string;
version?: string;
runtimes?: Array<"bun" | "node" | "edge" | "worker" | "service-worker" | "browser">;
requires?: Array<
"filesystem" | "tcp" | "process" | "websocket" | "crypto" | "streams" | "background-tasks"
>;
/** Framework capabilities requested by this package; applications may enforce explicit grants. */
permissions?: PluginPermission[];
compatibility?: {
bunMin?: string;
os?: string[];
};
plugin?: string | PackagePluginManifest;
components?: string[];
clientRuntimes?: ClientRuntimeDefinition[];
@@ -199,5 +286,9 @@ export interface PluginRunner {
contributions(): Promise<PluginContributions>;
devToolbarPanels(): Promise<PluginDevToolbarPanel[]>;
transformRoutes(routes: unknown[]): Promise<unknown[]>;
hook(name: "buildStart" | "buildEnd" | "configureServer", value?: unknown): Promise<void>;
render(html: string): Promise<string>;
hook(
name: "buildStart" | "buildEnd" | "configureServer" | "deploy" | "shutdown" | "hmrUpdate",
value?: unknown,
): Promise<void>;
}
@@ -0,0 +1,27 @@
import { expect, test } from "bun:test";
import { testPluginCompatibility } from "../src/index.ts";
test("plugin compatibility kit evaluates runtime, Bun, OS, and capability matrices", () => {
const results = testPluginCompatibility(
{
runtimes: ["bun", "node"],
requires: ["filesystem"],
compatibility: { bunMin: "1.3.0", os: ["linux", "darwin"] },
},
[
{ runtime: "bun", version: "1.3.2", os: "linux", capabilities: ["filesystem"] },
{ runtime: "bun", version: "1.2.9", os: "win32", capabilities: [] },
{ runtime: "edge", os: "linux", capabilities: ["crypto"] },
],
);
expect(results[0]?.ok).toBe(true);
expect(results[1]?.issues.map((issue) => issue.code)).toEqual([
"WRN-PLUGIN-MATRIX-VERSION",
"WRN-PLUGIN-MATRIX-OS",
"WRN-PLUGIN-MATRIX-CAPABILITY",
]);
expect(results[2]?.issues.map((issue) => issue.code)).toEqual([
"WRN-PLUGIN-MATRIX-RUNTIME",
"WRN-PLUGIN-MATRIX-CAPABILITY",
]);
});
+170
View File
@@ -3,6 +3,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createPluginRunner, discoverPlugins, resolvePlugins } from "../src/index.ts";
import { parse } from "@wrnexus/syntax";
describe("plugin ordering", () => {
test("orders pre, normal, and post plugins", () => {
@@ -22,6 +23,85 @@ describe("plugin ordering", () => {
});
});
test("runs the complete lifecycle and exposes ecosystem contribution channels", async () => {
const calls: string[] = [];
const runner = createPluginRunner(
{
name: "ecosystem",
setup: () => {
calls.push("setup");
},
configure: () => {
calls.push("configure");
},
configSchemas: [
{
namespace: "feature",
validate(value) {
calls.push(`schema:${String(value)}`);
},
},
],
directives: [
{
name: "focus",
transform: (value) => ({ name: "data-focus", value }),
},
],
cliCommands: [
{
name: "hello",
run: () => {
calls.push("cli");
},
},
],
virtualModules: [{ id: "virtual:feature", load: () => "export default true" }],
deploymentAdapters: [{ name: "test-cloud", build: (value) => value }],
documentation: ["docs/feature.md"],
typeDefinitions: ["types/feature.d.ts"],
render: (html) => `${html}<!-- plugin -->`,
hmrUpdate: (files) => {
calls.push(`hmr:${files.join(",")}`);
},
deploy: () => {
calls.push("deploy");
},
shutdown: () => {
calls.push("shutdown");
},
},
context("."),
);
await runner.configure({ feature: true });
await runner.configResolved({ feature: true });
const contributions = await runner.contributions();
expect(contributions.directives[0]?.name).toBe("focus");
expect(contributions.cliCommands[0]?.name).toBe("hello");
expect(contributions.virtualModules[0]?.id).toBe("virtual:feature");
expect(contributions.deploymentAdapters[0]?.name).toBe("test-cloud");
expect(contributions.documentation).toEqual(["docs/feature.md"]);
expect(contributions.typeDefinitions).toEqual(["types/feature.d.ts"]);
const transformed = await runner.transformAst(
parse('page Demo { view { <input use:focus="first"> } }'),
"app/pages/demo.wrn",
);
const input = transformed.view.find((node) => node.type === "element");
expect(input?.type === "element" ? input.attrs[0]?.name : undefined).toBe("data-focus");
expect(await runner.render("<main></main>")).toContain("<!-- plugin -->");
await runner.hook("hmrUpdate", ["app/page.wrn"]);
await runner.hook("deploy", {});
await runner.hook("shutdown");
expect(calls).toEqual([
"setup",
"configure",
"schema:true",
"hmr:app/page.wrn",
"deploy",
"shutdown",
]);
});
function context(root: string) {
return {
root,
@@ -185,6 +265,96 @@ test("strict discovery surfaces invalid package plugin exports", async () => {
}
});
test("discovery enforces declared deployment runtimes and capabilities", async () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-runtime-discovery-"));
const app = join(root, "apps", "web");
const pkg = join(root, "packages", "filesystem-plugin");
try {
mkdirSync(app, { recursive: true });
mkdirSync(pkg, { recursive: true });
writeFileSync(
join(root, "package.json"),
JSON.stringify({ private: true, workspaces: ["apps/*", "packages/*"] }),
);
writeFileSync(
join(app, "package.json"),
JSON.stringify({ name: "web", dependencies: { "filesystem-plugin": "workspace:*" } }),
);
writeFileSync(
join(pkg, "package.json"),
JSON.stringify({
name: "filesystem-plugin",
version: "1.0.0",
wrnexus: { runtimes: ["bun", "node"], requires: ["filesystem"] },
}),
);
await expect(
discoverPlugins(app, undefined, { runtime: "edge", capabilities: ["crypto"] }),
).rejects.toThrow("WRN-PLUGIN-RUNTIME");
await expect(
discoverPlugins(app, undefined, { runtime: "bun", capabilities: ["filesystem"] }),
).resolves.toBeDefined();
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("discovery enforces declared and application-granted plugin permissions", async () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-permission-discovery-"));
const app = join(root, "apps", "web");
const pkg = join(root, "packages", "route-plugin");
try {
mkdirSync(app, { recursive: true });
mkdirSync(pkg, { recursive: true });
writeFileSync(
join(root, "package.json"),
JSON.stringify({ private: true, workspaces: ["apps/*", "packages/*"] }),
);
writeFileSync(
join(app, "package.json"),
JSON.stringify({ name: "web", dependencies: { "route-plugin": "workspace:*" } }),
);
writeFileSync(join(pkg, "route.ts"), "export default {};");
writeFileSync(
join(pkg, "package.json"),
JSON.stringify({
name: "route-plugin",
version: "1.0.0",
wrnexus: {
permissions: ["routes"],
routes: [{ kind: "api", path: "/api/plugin", entry: "./route.ts" }],
},
}),
);
await expect(
discoverPlugins(app, undefined, { enforcePermissions: true, grantedPermissions: {} }),
).rejects.toThrow("WRN-PLUGIN-PERMISSION-DENIED");
await expect(
discoverPlugins(app, undefined, {
enforcePermissions: true,
grantedPermissions: { "route-plugin": ["routes"] },
}),
).resolves.toBeDefined();
writeFileSync(
join(pkg, "package.json"),
JSON.stringify({
name: "route-plugin",
version: "1.0.0",
wrnexus: { routes: [{ kind: "api", path: "/api/plugin", entry: "./route.ts" }] },
}),
);
await expect(
discoverPlugins(app, undefined, {
enforcePermissions: true,
grantedPermissions: { "route-plugin": ["routes"] },
}),
).rejects.toThrow("WRN-PLUGIN-PERMISSION-UNDECLARED");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("orders package style contributions by pre, normal, and post", async () => {
const runner = createPluginRunner(
{