/**
* Programmatic plugin-build API.
*
* `emdash-plugin build` produces the on-disk distribution artifacts
* for an npm-installed sandboxed plugin:
*
* - `dist/plugin.mjs` (+ `dist/plugin.d.mts`) — runtime bytes (hooks +
* routes), built with `emdash` aliased to a no-op shim. The same
* artifact is consumed two ways at install time:
* 1. In-process (`plugins: [...]`): the integration `import`s the
* package's `./sandbox` export and wraps the default with
* `adaptSandboxEntry`.
* 2. Isolate (`sandboxed: [...]`): the integration resolves the
* same `./sandbox` export, reads the file's bytes, and
* string-embeds them into a generated module the sandbox
* runner loads.
* - `dist/manifest.json` — wire-shape `PluginManifest`. Same shape
* the registry bundle tarball carries; `bundle` packs this file
* verbatim (renaming `plugin.mjs` → `backend.js` inside the
* archive). Includes hooks + routes harvested from probing
* `src/plugin.ts`.
* - `dist/index.mjs` (+ `dist/index.d.mts`) — descriptor module,
* default-exporting the bare `PluginDescriptor`. Emitted only
* when a sibling `package.json` exists (registry-only plugins
* skip this because nothing would `import` it).
*
* The plugin author writes only `emdash-plugin.jsonc` + `src/plugin.ts`.
* Identity (slug, publisher) and trust contract (capabilities,
* allowedHosts, storage) come from the manifest; the version is either
* in the manifest or in `package.json#version` (`normaliseManifest`
* reconciles).
*
* Failures throw `BuildError` with a structured `code`.
*/
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import type { PluginManifest, ResolvedPlugin } from "../bundle/types.js";
import { extractManifest } from "../bundle/utils.js";
import type { NormalisedManifest } from "../manifest/translate.js";
import {
buildRuntime,
probeAndAssemble,
resolveSources,
BuildPipelineError,
type BuildPipelineErrorCode,
type PipelineLogger,
type ResolvedSources,
} from "./pipeline.js";
// ──────────────────────────────────────────────────────────────────────────
// Public types
// ──────────────────────────────────────────────────────────────────────────
export type BuildErrorCode = BuildPipelineErrorCode;
export class BuildError extends Error {
override readonly name = "BuildError";
readonly code: BuildErrorCode;
constructor(code: BuildErrorCode, message: string) {
super(message);
this.code = code;
}
}
export type BuildLogger = PipelineLogger;
export interface BuildOptions {
/** Plugin source directory, must contain `emdash-plugin.jsonc` + `src/plugin.ts`. */
dir: string;
/**
* Output directory for `dist/*`, relative to `dir` if not absolute.
* Defaults to `
/dist`.
*/
outDir?: string;
/** Optional progress reporter. */
logger?: BuildLogger;
}
export interface BuildResult {
/** The normalised source manifest (post-version-reconciliation). */
manifest: NormalisedManifest;
/** Package name from `package.json#name`, or `undefined` (registry-only plugin). */
packageName: string | undefined;
/**
* Wire-shape manifest written to `dist/manifest.json`. Includes
* hooks + routes harvested from probing `src/plugin.ts`. Bundle
* consumes this directly when packing the tarball.
*/
wireManifest: PluginManifest;
/**
* The probed `ResolvedPlugin` — manifest identity + trust contract
* plus harvested hook/route handlers. Bundle uses this for its
* trusted-only / admin-route consistency checks without re-probing.
*/
resolvedPlugin: ResolvedPlugin;
/** Absolute path of the dist directory. */
outDir: string;
/** Absolute paths of the files produced. */
files: {
runtime: string;
runtimeTypes: string;
manifestJson: string;
/** Only set when `package.json` exists. */
descriptor: string | undefined;
/** Only set when `package.json` exists. */
descriptorTypes: string | undefined;
};
}
// ──────────────────────────────────────────────────────────────────────────
// Implementation
// ──────────────────────────────────────────────────────────────────────────
export async function buildPlugin(options: BuildOptions): Promise {
const log = options.logger ?? {};
const pluginDir = resolve(options.dir);
const outDir = resolve(pluginDir, options.outDir ?? "dist");
log.start?.("Building plugin...");
let sources: ResolvedSources;
try {
sources = await resolveSources(pluginDir, log);
} catch (error) {
if (error instanceof BuildPipelineError) {
throw new BuildError(error.code, error.message);
}
throw error;
}
const tmpDir = await mkdtemp(join(tmpdir(), "emdash-build-"));
try {
const { build } = await import("tsdown");
await mkdir(outDir, { recursive: true });
// ── 1. Build src/plugin.ts → dist/plugin.mjs (+ .d.mts) ──
log.start?.("Building runtime entry...");
const runtimeFiles = await runPipelineStep(() =>
buildRuntime({
entries: sources,
outDir,
tmpDir,
build,
}),
);
log.success?.("Built plugin.mjs");
// ── 2. Probe src/plugin.ts for hooks + routes ──
log.start?.("Probing plugin surface...");
const resolvedPlugin = await runPipelineStep(() =>
probeAndAssemble({
entries: sources,
tmpDir,
build,
}),
);
const wireManifest = extractManifest(resolvedPlugin);
log.info?.(
` Hooks: ${
wireManifest.hooks.length > 0
? wireManifest.hooks.map((h) => (typeof h === "string" ? h : h.name)).join(", ")
: "(none)"
}`,
);
log.info?.(
` Routes: ${
wireManifest.routes.length > 0
? wireManifest.routes.map((r) => (typeof r === "string" ? r : r.name)).join(", ")
: "(none)"
}`,
);
// ── 3. Write dist/manifest.json (wire shape) ──
const manifestJson = join(outDir, "manifest.json");
await writeFile(manifestJson, `${JSON.stringify(wireManifest, null, 2)}\n`, "utf-8");
log.success?.("Wrote manifest.json");
// ── 4. Generate dist/index.mjs (+ .d.mts) — descriptor module ──
// Only emitted when a sibling package.json exists. Registry-only
// plugins (no package.json) can't be `pnpm add`-ed, so nothing
// would `import` the descriptor module.
let descriptor: string | undefined;
let descriptorTypes: string | undefined;
if (sources.hasPackageJson && sources.packageName) {
log.start?.("Generating descriptor module...");
({ descriptor, descriptorTypes } = await writeDescriptor({
outDir,
manifest: sources.manifest,
packageName: sources.packageName,
}));
log.success?.("Wrote index.mjs");
} else {
log.info?.("No package.json — skipping dist/index.mjs (registry-only plugin)");
}
log.success?.(`Plugin built: ${sources.manifest.slug}@${sources.manifest.version}`);
return {
manifest: sources.manifest,
packageName: sources.packageName,
wireManifest,
resolvedPlugin,
outDir,
files: {
runtime: runtimeFiles.runtime,
runtimeTypes: runtimeFiles.runtimeTypes,
manifestJson,
descriptor,
descriptorTypes,
},
};
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
}
// ──────────────────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────────────────
/**
* Translate `BuildPipelineError` to `BuildError`. Other errors pass through.
*/
async function runPipelineStep(fn: () => Promise): Promise {
try {
return await fn();
} catch (error) {
if (error instanceof BuildPipelineError) {
throw new BuildError(error.code, error.message);
}
throw error;
}
}
interface WriteDescriptorContext {
outDir: string;
manifest: NormalisedManifest;
packageName: string;
}
interface DescriptorFiles {
descriptor: string;
descriptorTypes: string;
}
/**
* Emit `dist/index.mjs` + `dist/index.d.mts`.
*
* The descriptor is a frozen plain object — no factory call, no named
* exports. Consumers write `import auditLog from "@.../plugin-audit-log"`
* and pass `auditLog` into the integration's `plugins:` or `sandboxed:`
* array directly. Per-install configuration moves to the admin UI's
* settings (KV-backed) so the import shape has no need for a factory.
*
* The descriptor's `entrypoint` is `/sandbox`. Plugins
* MUST expose a `./sandbox` export in their `package.json` pointing at
* `./dist/plugin.mjs` — the runtime bytes the integration loads.
*/
async function writeDescriptor(ctx: WriteDescriptorContext): Promise {
const { outDir, manifest, packageName } = ctx;
const descriptorObject = {
id: manifest.slug,
version: manifest.version,
format: "standard" as const,
entrypoint: `${packageName}/sandbox`,
capabilities: manifest.capabilities,
allowedHosts: manifest.allowedHosts,
storage: manifest.storage,
...(manifest.admin.pages.length > 0 ? { adminPages: manifest.admin.pages } : {}),
...(manifest.admin.widgets.length > 0 ? { adminWidgets: manifest.admin.widgets } : {}),
};
// Pretty-print so the generated file is human-readable when debugging.
// Tab-indent for consistency with the surrounding generated file's
// surrounding tab-based formatting; matches the project's oxfmt config.
const descriptorLiteral = JSON.stringify(descriptorObject, null, "\t");
const descriptorSource = `// Auto-generated by emdash-plugin build. Do not edit.
// Source: emdash-plugin.jsonc + package.json
//
// Default-exports a sandboxed plugin descriptor. Pass it directly into
// emdash's \`plugins:\` or \`sandboxed:\` array — no factory call needed.
/** @type {import("emdash").PluginDescriptor} */
const descriptor = Object.freeze(${descriptorLiteral});
export default descriptor;
`;
const descriptorPath = join(outDir, "index.mjs");
await writeFile(descriptorPath, descriptorSource, "utf-8");
const descriptorTypesSource = `// Auto-generated by emdash-plugin build. Do not edit.
import type { PluginDescriptor } from "emdash";
declare const descriptor: PluginDescriptor;
export default descriptor;
`;
const descriptorTypesPath = join(outDir, "index.d.mts");
await writeFile(descriptorTypesPath, descriptorTypesSource, "utf-8");
return { descriptor: descriptorPath, descriptorTypes: descriptorTypesPath };
}