chore: import upstream snapshot with attribution
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:23:53 +08:00
commit b3a7f98e5a
3020 changed files with 935484 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
/**
* Programmatic API for `@emdash-cms/plugin-cli`.
*
* Most users will run the CLI binary `emdash-plugin`. This entry exists for
* tooling -- editors, custom build scripts, or other CLIs -- that want to
* invoke the same logic without spawning a subprocess.
*
* For discovery and credential storage, import from `@emdash-cms/registry-client`
* directly. This package is the publisher-side surface (bundle, publish).
*
* EXPERIMENTAL: pin to an exact version while RFC 0001 is in flight.
*/
export {
type BuildErrorCode,
type BuildLogger,
type BuildOptions,
type BuildResult,
BuildError,
buildPlugin,
} from "./build/api.js";
export {
type BundleErrorCode,
type BundleLogger,
type BundleOptions,
type BundleResult,
BundleError,
bundlePlugin,
} from "./bundle/api.js";
export {
type ProfileBootstrap,
type ProfileInput,
type PublishErrorCode,
type PublishLogger,
type PublishOptions,
type PublishResult,
PublishError,
publishRelease,
} from "./publish/api.js";
// `sanitiseSlug` was previously exported from `./publish/api.js`. The
// canonical helper now lives in `@emdash-cms/plugin-types` as
// `deriveSlugFromId` (alongside the validation regex constants and
// `isPluginSlug`); we re-export it here for one release cycle to ease
// migration. New code should import from `@emdash-cms/plugin-types` directly.
export {
deriveSlugFromId,
isPluginSlug,
isPluginVersion,
PLUGIN_SLUG_RE,
PLUGIN_VERSION_RE,
} from "@emdash-cms/plugin-types";
// Re-export the manifest contract types so consumers don't need a separate
// `@emdash-cms/plugin-types` dep just to type their input/output. They're
// authored upstream; this is a convenience surface, not a copy.
export {
type CurrentPluginCapability,
type DeprecatedPluginCapability,
type ManifestHookEntry,
type ManifestRouteEntry,
type PluginAdminConfig,
type PluginCapability,
type PluginManifest,
type PluginStorageConfig,
type StorageCollectionConfig,
CAPABILITY_RENAMES,
isDeprecatedCapability,
normalizeCapabilities,
normalizeCapability,
} from "@emdash-cms/plugin-types";
export { sha256Multihash } from "./multihash.js";
+311
View File
@@ -0,0 +1,311 @@
/**
* 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 `<dir>/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<BuildResult> {
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<T>(fn: () => Promise<T>): Promise<T> {
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 `<package-name>/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<DescriptorFiles> {
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 };
}
+69
View File
@@ -0,0 +1,69 @@
/**
* `emdash-plugin build`
*
* Thin citty wrapper around `buildPlugin` from `./api.js`. Produces the
* on-disk dist artifacts for an npm-installed sandboxed plugin:
*
* - `dist/plugin.mjs` (+ `.d.mts`) — runtime bytes (hooks + routes).
* - `dist/index.mjs` (+ `.d.mts`) — descriptor module, bare default export.
* - `dist/manifest.json` — normalised manifest, kept in sync.
*
* Plugin authors run this from their package's `prepublishOnly` (or a
* `pnpm build` script that delegates). Bundling for the registry (the
* tarball form) is a separate command — see `emdash-plugin bundle`.
*/
import { defineCommand } from "citty";
import consola from "consola";
import pc from "picocolors";
import { BuildError, buildPlugin, type BuildLogger } from "./api.js";
export const buildCommand = defineCommand({
meta: {
name: "build",
description: "Build a sandboxed plugin's npm distribution artifacts",
},
args: {
dir: {
type: "string",
description: "Plugin directory (default: current directory)",
default: process.cwd(),
},
"out-dir": {
type: "string",
alias: "o",
description: "Output directory (default: ./dist)",
default: "dist",
},
},
async run({ args }) {
const logger: BuildLogger = {
start: (m) => consola.start(m),
info: (m) => consola.info(m),
success: (m) => consola.success(m),
warn: (m) => consola.warn(m),
};
let result;
try {
result = await buildPlugin({
dir: args.dir,
outDir: args["out-dir"],
logger,
});
} catch (error) {
if (error instanceof BuildError) {
consola.error(error.message);
process.exit(1);
}
throw error;
}
console.log();
consola.info("Output:");
console.log(` ${pc.cyan(result.files.descriptor)}`);
console.log(` ${pc.cyan(result.files.runtime)}`);
console.log(` ${pc.cyan(result.files.manifestJson)}`);
},
});
+567
View File
@@ -0,0 +1,567 @@
/**
* Shared build pipeline used by `build` and `bundle`.
*
* One canonical source-to-artifact pipeline so neither `build` nor `bundle`
* has to maintain its own copy of the probe + transpile + extract logic.
* `build` writes the dist artifacts to disk; `bundle` calls into the same
* machinery to produce a `ResolvedPlugin` it then validates and tarballs.
*
* The phases:
*
* 1. `resolveSources(pluginDir)` — read + normalise `emdash-plugin.jsonc`,
* optionally read `package.json` for name/version, locate `src/plugin.ts`.
* Reconciles the manifest's optional `version` with `package.json#version`
* via `normaliseManifest` (mismatch / missing → error).
*
* 2. `probeAndAssemble({ entries, tmpDir })` — build `src/plugin.ts`
* unminified to a temp file, dynamically `import()` it, and harvest
* the hook/route surface into a `ResolvedPlugin`. Identity + trust
* contract come from the manifest, not the code.
*
* 3. `buildRuntime({ entries, outDir, tmpDir })` — build `src/plugin.ts`
* again, this time minified + tree-shaken + with `.d.mts` types, to
* produce `<outDir>/plugin.mjs` and `<outDir>/plugin.d.mts`. Probe
* and runtime builds differ deliberately in minification and dts
* output; the probe only reads `default.hooks` / `default.routes`
* *keys*, which minification doesn't rename (object literal keys
* stay stable). Both pass the same source through tsdown with no
* `external` and no `alias` — sandboxed plugins must not import
* from `emdash` at runtime (types come from `emdash/plugin` and
* are erased before bundling).
*
* Errors throw `BuildPipelineError` with a structured code. Wrappers translate
* to their own error classes so the CLI's `BuildError` / `BundleError`
* surfaces don't change.
*/
import { copyFile, mkdir, readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import type { ResolvedPlugin } from "../bundle/types.js";
import { fileExists } from "../bundle/utils.js";
import {
ManifestError,
MANIFEST_FILENAME,
loadManifest,
type LoadManifestResult,
} from "../manifest/load.js";
import {
normaliseManifest,
VersionMismatchError,
type NormalisedManifest,
} from "../manifest/translate.js";
import {
ProbedDefaultSchema,
type ProbedDefault,
type ProbedHookEntry,
type ProbedRouteEntry,
} from "./probe-schema.js";
const PLUGIN_ENTRY_PATH = "src/plugin.ts";
const PACKAGE_JSON_PATH = "package.json";
// ──────────────────────────────────────────────────────────────────────────
// Errors
// ──────────────────────────────────────────────────────────────────────────
export type BuildPipelineErrorCode =
| "MISSING_MANIFEST"
| "MISSING_PLUGIN_ENTRY"
| "MANIFEST_INVALID"
| "PACKAGE_JSON_INVALID"
| "VERSION_MISMATCH"
| "VERSION_MISSING"
| "RUNTIME_BUILD_FAILED"
| "PROBE_BUILD_FAILED"
| "INVALID_PLUGIN_FORMAT";
export class BuildPipelineError extends Error {
override readonly name = "BuildPipelineError";
readonly code: BuildPipelineErrorCode;
constructor(code: BuildPipelineErrorCode, message: string) {
super(message);
this.code = code;
}
}
// ──────────────────────────────────────────────────────────────────────────
// Logger surface (shared by build + bundle wrappers)
// ──────────────────────────────────────────────────────────────────────────
export interface PipelineLogger {
start?(message: string): void;
info?(message: string): void;
success?(message: string): void;
warn?(message: string): void;
}
// ──────────────────────────────────────────────────────────────────────────
// Phase 1: source resolution
// ──────────────────────────────────────────────────────────────────────────
export interface ResolvedSources {
pluginDir: string;
pluginEntry: string;
manifest: NormalisedManifest;
manifestPath: string;
/**
* Package name from `package.json#name`, or `undefined` if no
* `package.json` exists (registry-only plugin).
*/
packageName: string | undefined;
/**
* Whether a sibling `package.json` was found. Determines whether
* the descriptor module (`dist/index.mjs`) is emitted — a plugin
* without `package.json` can't be `pnpm add`-ed, so the descriptor
* has no consumer.
*/
hasPackageJson: boolean;
}
export async function resolveSources(
pluginDir: string,
log: PipelineLogger = {},
): Promise<ResolvedSources> {
const resolvedDir = resolve(pluginDir);
const manifestPath = join(resolvedDir, MANIFEST_FILENAME);
if (!(await fileExists(manifestPath))) {
throw new BuildPipelineError(
"MISSING_MANIFEST",
`No ${MANIFEST_FILENAME} found in ${resolvedDir}. Scaffold one with: emdash-plugin init`,
);
}
let loaded: LoadManifestResult;
try {
loaded = await loadManifest(manifestPath);
} catch (error) {
if (error instanceof ManifestError) {
throw new BuildPipelineError("MANIFEST_INVALID", error.message);
}
throw error;
}
const pluginEntry = join(resolvedDir, PLUGIN_ENTRY_PATH);
if (!(await fileExists(pluginEntry))) {
throw new BuildPipelineError(
"MISSING_PLUGIN_ENTRY",
`No ${PLUGIN_ENTRY_PATH} found in ${resolvedDir}. Sandboxed plugins place their routes and hooks in this single file.`,
);
}
// `package.json` is optional. Common case (npm-distributed plugin):
// present, drives the version and the descriptor's entrypoint
// specifier. Edge case (registry-only plugin): absent, version
// lives in the manifest, no descriptor module is emitted.
const packageJsonPath = join(resolvedDir, PACKAGE_JSON_PATH);
const hasPackageJson = await fileExists(packageJsonPath);
let packageName: string | undefined;
let packageVersion: string | undefined;
if (hasPackageJson) {
({ packageName, packageVersion } = await readPackageMeta(packageJsonPath));
}
let manifest: NormalisedManifest;
try {
manifest = normaliseManifest(loaded.manifest, packageVersion);
} catch (error) {
if (error instanceof VersionMismatchError) {
throw new BuildPipelineError(error.code, error.message);
}
throw error;
}
log.info?.(`Manifest: ${loaded.path}`);
log.info?.(`Plugin entry: ${pluginEntry}`);
if (packageName) log.info?.(`Package: ${packageName}`);
return {
pluginDir: resolvedDir,
pluginEntry,
manifest,
manifestPath: loaded.path,
packageName,
hasPackageJson,
};
}
interface PackageMeta {
packageName: string;
packageVersion: string | undefined;
}
async function readPackageMeta(packageJsonPath: string): Promise<PackageMeta> {
const source = await readFile(packageJsonPath, "utf-8");
let parsed: unknown;
try {
parsed = JSON.parse(source);
} catch {
throw new BuildPipelineError("PACKAGE_JSON_INVALID", `${packageJsonPath} is not valid JSON.`);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new BuildPipelineError(
"PACKAGE_JSON_INVALID",
`${packageJsonPath} must be a JSON object.`,
);
}
const name = (parsed as { name?: unknown }).name;
if (typeof name !== "string" || name.length === 0) {
throw new BuildPipelineError(
"PACKAGE_JSON_INVALID",
`${packageJsonPath} has no "name" field. The build derives the runtime entrypoint specifier from package.json#name.`,
);
}
// `version` is optional (registry-only plugins may rely on the
// manifest's version); when present, it must be a non-empty
// string.
const versionRaw = (parsed as { version?: unknown }).version;
let packageVersion: string | undefined;
if (versionRaw === undefined) {
packageVersion = undefined;
} else if (typeof versionRaw === "string" && versionRaw.length > 0) {
packageVersion = versionRaw;
} else {
throw new BuildPipelineError(
"PACKAGE_JSON_INVALID",
`${packageJsonPath} has a non-string or empty \`version\` (${JSON.stringify(versionRaw)}). Either remove the field (registry-only plugins) or set it to a non-empty string.`,
);
}
return { packageName: name, packageVersion };
}
// ──────────────────────────────────────────────────────────────────────────
// Phase 2: probe + assemble
// ──────────────────────────────────────────────────────────────────────────
export interface ProbeAndAssembleContext {
entries: ResolvedSources;
tmpDir: string;
build: typeof import("tsdown").build;
}
/**
* Build `src/plugin.ts` once for hook/route probing, import it, and
* assemble a `ResolvedPlugin` from the manifest's identity / trust
* contract plus the probed surface.
*
* The probe build is *not* minified — keeping function bodies intact
* makes the `pluginModule.default.hooks[x]` handler reads stable. The
* later runtime build is minified separately by `buildRuntime`.
*/
export async function probeAndAssemble(ctx: ProbeAndAssembleContext): Promise<ResolvedPlugin> {
const { entries, tmpDir, build } = ctx;
const resolvedPlugin: ResolvedPlugin = {
// `id` on the bundled manifest is the publisher's natural slug.
// The runtime rewrites it to the opaque `r_<hash>` at install
// time (see makeRegistryPluginId), but on-wire the slug is what
// the install handler matches against the registry's record key.
id: entries.manifest.slug,
version: entries.manifest.version,
capabilities: entries.manifest.capabilities,
allowedHosts: entries.manifest.allowedHosts,
storage: entries.manifest.storage,
hooks: {},
routes: {},
admin: {
pages: entries.manifest.admin.pages,
widgets: entries.manifest.admin.widgets,
},
};
const probeOutDir = join(tmpDir, "plugin-probe");
try {
await build({
config: false,
entry: { plugin: entries.pluginEntry },
format: "esm",
outExtensions: () => ({ js: ".mjs" }),
outDir: probeOutDir,
dts: false,
platform: "neutral",
external: [],
treeshake: true,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new BuildPipelineError(
"PROBE_BUILD_FAILED",
`Failed to probe ${entries.pluginEntry}: ${message}`,
);
}
const probeOutputPath = join(probeOutDir, "plugin.mjs");
if (!(await fileExists(probeOutputPath))) {
throw new BuildPipelineError(
"PROBE_BUILD_FAILED",
`Probe of ${entries.pluginEntry} produced no output at ${probeOutputPath}.`,
);
}
const pluginModule: unknown = await import(pathToFileURL(probeOutputPath).href);
if (!isObjectRecord(pluginModule)) {
throw new BuildPipelineError(
"INVALID_PLUGIN_FORMAT",
`${entries.pluginEntry} did not produce a module object on probe (got ${describeShape(pluginModule)}).`,
);
}
if (pluginModule.default === undefined) {
throw new BuildPipelineError(
"INVALID_PLUGIN_FORMAT",
`${entries.pluginEntry} has no \`default\` export. Sandboxed plugins must \`export default { hooks, routes } satisfies SandboxedPlugin\` from "emdash/plugin". A named-only export (e.g. \`export const plugin = ...\`) produces an empty bundle.`,
);
}
const definition = pluginModule.default;
if (!isObjectRecord(definition)) {
throw new BuildPipelineError(
"INVALID_PLUGIN_FORMAT",
`${entries.pluginEntry} must default-export an object with \`hooks\` and/or \`routes\` (sandboxed plugin shape: \`export default { hooks, routes } satisfies SandboxedPlugin\` from "emdash/plugin"). Got ${describeShape(definition)}.`,
);
}
const parsed = parseProbedDefault(entries.pluginEntry, definition);
if (parsed.hooks) {
for (const [hookName, hookEntry] of Object.entries(parsed.hooks)) {
resolvedPlugin.hooks[hookName] = assembleHook(hookEntry, resolvedPlugin.id);
}
}
if (parsed.routes) {
for (const [routeName, routeEntry] of Object.entries(parsed.routes)) {
resolvedPlugin.routes[routeName] = assembleRoute(routeEntry);
}
}
return resolvedPlugin;
}
/**
* Validate the probed default export against `ProbedDefaultSchema` and
* translate any Zod issue into a `BuildPipelineError`. The first issue
* wins so authors see one focused message rather than an issue tree.
*
* Path-keyed dispatch: an issue at `["hooks", "X"]` produces the
* "must be a function or { handler, ... }" message; an issue at
* `["hooks", "X", "field", ...]` produces the "has invalid FIELD VALUE"
* message. Anything else falls back to a generic path-prefixed message.
*
* Exported so tests can lock in the error-message contract without
* spinning up a real probe build.
*/
export function parseProbedDefault(pluginEntry: string, definition: unknown): ProbedDefault {
let result: ReturnType<typeof ProbedDefaultSchema.safeParse>;
try {
result = ProbedDefaultSchema.safeParse(definition);
} catch (error) {
// Defensive: Zod 4 has been observed to throw `TypeError` when an
// entry is an exotic shape it doesn't expect. The schema's
// `normaliseEntry` preprocess catches the cases we know about,
// but wrap `safeParse` so any future surprise still surfaces as a
// `BuildPipelineError` rather than a raw stack trace.
const message = error instanceof Error ? error.message : String(error);
throw new BuildPipelineError(
"INVALID_PLUGIN_FORMAT",
`${pluginEntry}: probed default export could not be validated (${message}). Check for entries with unusual shapes (Promises, class instances, etc.).`,
);
}
if (result.success) return result.data;
const issue = result.error.issues[0];
if (!issue) {
throw new BuildPipelineError(
"INVALID_PLUGIN_FORMAT",
`${pluginEntry}: probed default export failed validation.`,
);
}
const [collection, entryName, ...rest] = issue.path;
if ((collection === "hooks" || collection === "routes") && typeof entryName === "string") {
const kind = collection === "hooks" ? "hook" : "route";
const entry = getProperty(getProperty(definition, collection), entryName);
if (rest.length === 0) {
throw new BuildPipelineError(
"INVALID_PLUGIN_FORMAT",
`${pluginEntry}: ${kind} "${entryName}" must be a function or { handler: function, ... }. Got ${describeShape(entry)}.`,
);
}
// Per-field issue (errorPolicy, priority, timeout, dependencies,
// public, …). The displayed value is the field as a whole, not
// the deeper element the path points at; the Zod message
// describes the actual fault.
const field = formatFieldPath(rest);
const fieldValue = typeof rest[0] === "string" ? getProperty(entry, rest[0]) : undefined;
throw new BuildPipelineError(
"INVALID_PLUGIN_FORMAT",
`${pluginEntry}: ${kind} "${entryName}" has invalid ${field} ${safeStringify(fieldValue)} (${issue.message}).`,
);
}
throw new BuildPipelineError(
"INVALID_PLUGIN_FORMAT",
`${pluginEntry}: ${issue.message} (at ${issue.path.join(".") || "<root>"}).`,
);
}
/**
* Render an issue path inside a hook/route entry as `field`,
* `field[index]`, or `field.sub`. Used for human-readable error
* messages only; never fed back into property lookups.
*/
function formatFieldPath(path: readonly PropertyKey[]): string {
let out = "";
for (const segment of path) {
if (typeof segment === "number") {
out += `[${segment}]`;
} else if (out === "") {
out += String(segment);
} else {
out += `.${String(segment)}`;
}
}
return out || "<unknown>";
}
/**
* Read a property off an `unknown` value, returning `undefined` for any
* non-object input. Used only to recover the original user-supplied
* value back off the definition for error-message formatting, never to
* drive control flow. Exotic objects (Array, Map, Date, class
* instances) return whatever the runtime gives them — harmless for
* an error-message helper.
*/
function getProperty(value: unknown, key: string): unknown {
if (value === null || typeof value !== "object") return undefined;
// eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- error-message helper; widening to Record<string,unknown> for plain-object lookup is intentional, exotic objects return harmless results
return (value as Record<string, unknown>)[key];
}
/**
* `JSON.stringify` that survives values it can't serialise (`BigInt`,
* cyclic structures, `undefined`, functions, symbols) by falling back
* to `describeShape`. Used only to embed user-supplied values in
* `BuildPipelineError` messages.
*/
function safeStringify(value: unknown): string {
try {
const json = JSON.stringify(value, (_key, v) =>
typeof v === "bigint" ? `${v.toString()}n` : v,
);
// `JSON.stringify(undefined)` is `undefined` (not a string). Fall
// through to the shape description in that case.
if (json === undefined) return describeShape(value);
return json;
} catch {
return describeShape(value);
}
}
function assembleHook(entry: ProbedHookEntry, pluginId: string): ResolvedPlugin["hooks"][string] {
// `preprocess` in `probe-schema.ts` normalises the bare-function form
// into `{ handler }` before validation, so every entry that reaches
// here is in the config form.
return {
handler: entry.handler,
priority: entry.priority ?? 100,
timeout: entry.timeout ?? 5000,
dependencies: entry.dependencies ?? [],
errorPolicy: entry.errorPolicy ?? "abort",
exclusive: entry.exclusive ?? false,
pluginId,
};
}
function assembleRoute(entry: ProbedRouteEntry): ResolvedPlugin["routes"][string] {
return { handler: entry.handler, public: entry.public };
}
function isObjectRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
// ──────────────────────────────────────────────────────────────────────────
// Phase 3: runtime build
// ──────────────────────────────────────────────────────────────────────────
export interface BuildRuntimeContext {
entries: ResolvedSources;
outDir: string;
tmpDir: string;
build: typeof import("tsdown").build;
}
export interface RuntimeFiles {
runtime: string;
runtimeTypes: string;
}
/**
* Build `src/plugin.ts` into `<outDir>/plugin.mjs` + `<outDir>/plugin.d.mts`.
*
* Same source as the probe; the configuration differs only in
* `minify: true` and `dts: true`. The probe stays unminified for
* stable property-key reads (`default.hooks`, `default.routes`); the
* runtime build minifies because this output is what runs in the
* isolate (loader string-embeds it) or is `import`-ed in-process. No
* `external`, no `alias` — sandboxed plugins must not import from
* `emdash` at runtime.
*/
export async function buildRuntime(ctx: BuildRuntimeContext): Promise<RuntimeFiles> {
const { entries, outDir, tmpDir, build } = ctx;
const runtimeOutDir = join(tmpDir, "runtime");
try {
await build({
config: false,
entry: { plugin: entries.pluginEntry },
format: "esm",
outExtensions: () => ({ js: ".mjs", dts: ".d.mts" }),
outDir: runtimeOutDir,
dts: true,
platform: "neutral",
external: [],
minify: true,
treeshake: true,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new BuildPipelineError(
"RUNTIME_BUILD_FAILED",
`Failed to build ${entries.pluginEntry}: ${message}`,
);
}
const builtJs = join(runtimeOutDir, "plugin.mjs");
if (!(await fileExists(builtJs))) {
throw new BuildPipelineError(
"RUNTIME_BUILD_FAILED",
`Runtime build produced no plugin.mjs output for ${entries.pluginEntry}.`,
);
}
await mkdir(outDir, { recursive: true });
const runtime = join(outDir, "plugin.mjs");
await copyFile(builtJs, runtime);
const builtDts = join(runtimeOutDir, "plugin.d.mts");
const runtimeTypes = join(outDir, "plugin.d.mts");
if (await fileExists(builtDts)) {
await copyFile(builtDts, runtimeTypes);
}
return { runtime, runtimeTypes };
}
// ──────────────────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────────────────
function describeShape(value: unknown): string {
if (value === null) return "null";
if (value === undefined) return "undefined";
if (Array.isArray(value)) return `array (length ${value.length})`;
return typeof value;
}
@@ -0,0 +1,129 @@
/**
* Zod schema for the *probed* shape of a sandboxed plugin's compiled
* default export.
*
* The pipeline imports `src/plugin.ts` after a probe build and reads the
* default export to harvest the hook/route surface. The strict
* `SandboxedPlugin` type in `@emdash-cms/plugin-types` rejects bad
* shapes at compile time, but a plugin author can bypass typecheck
* (untyped JS, dynamic config, `// @ts-ignore`) and ship a malformed
* default export. This schema is the runtime contract the probe
* enforces against that case.
*
* Scope choices:
*
* - The probe only cares about hook/route *keys* and *config*, not
* handler bodies. Handlers are validated as "is a function";
* signatures aren't checked here (TypeScript already did that for
* compliant authors, and the runtime will reject malformed calls).
* - Per-entry configs use `looseObject` so plugins adding experimental
* fields don't break their own builds. The strict layer is the entry
* *shape*: a wrong-shaped entry produces a targeted error.
* - Bare-function form (`"content:beforeSave": async (e, ctx) => {...}`)
* is normalised via `z.preprocess` into `{ handler: fn }` so the
* schema only has to validate one shape per entry.
*/
import { z } from "zod";
/** A function reference; the probe doesn't introspect signatures. */
const FunctionSchema = z.custom<(...args: unknown[]) => unknown>(
(value) => typeof value === "function",
{ message: "must be a function" },
);
/**
* Map a probed hook/route entry into the canonical config form
* (`{ handler }`) for the schema.
*
* - Functions become `{ handler: fn }`.
* - Plain objects (prototype is `Object.prototype` or `null`) pass
* through; the schema validates their fields.
* - Anything else (built-ins like `Date`, `RegExp`, `Promise`, `Map`,
* author-defined class instances, primitives) is reduced to `null`
* so the schema produces a single "expected object" issue at the
* entry root. Without this, the schema would reach into the wrong-
* shaped object for `handler` and report a misleading "missing
* handler" issue.
*/
function normaliseEntry(value: unknown): unknown {
if (typeof value === "function") return { handler: value };
if (value === null || typeof value !== "object") return value;
const proto: unknown = Object.getPrototypeOf(value);
if (proto === Object.prototype || proto === null) return value;
return null;
}
/**
* Finite-number check that produces a single clean message regardless
* of how the input is invalid (wrong type, `NaN`, `Infinity`). Zod 4's
* `z.number()` rejects `NaN`/`Infinity` with its own per-case messages
* (`"expected number, received NaN"`) which read awkwardly to plugin
* authors; this custom check folds all three failure modes into the
* single "must be a finite number" line.
*/
const FiniteNumberSchema = z.custom<number>((v) => typeof v === "number" && Number.isFinite(v), {
message: "must be a finite number",
});
const NonNegativeFiniteNumberSchema = z.custom<number>(
(v) => typeof v === "number" && Number.isFinite(v) && v >= 0,
{ message: "must be a non-negative finite number" },
);
const HookEntryConfigSchema = z.looseObject({
handler: FunctionSchema,
priority: FiniteNumberSchema.optional(),
timeout: NonNegativeFiniteNumberSchema.optional(),
dependencies: z.array(z.string()).optional(),
errorPolicy: z
.enum(["continue", "abort"], {
message: `must be "continue" or "abort"`,
})
.optional(),
exclusive: z.boolean().optional(),
});
export const HookEntrySchema = z.preprocess(normaliseEntry, HookEntryConfigSchema);
const RouteEntryConfigSchema = z.looseObject({
handler: FunctionSchema,
public: z.boolean().optional(),
});
export const RouteEntrySchema = z.preprocess(normaliseEntry, RouteEntryConfigSchema);
/**
* Coerce a non-record `hooks` / `routes` collection to an empty
* object so it harvests no entries without failing the build. Plain
* records pass through; `undefined` is preserved so the wrapped
* `.optional()` accepts a missing collection.
*
* Returns `{}` rather than `undefined` so the wrapped `z.record` /
* `.optional()` chain composes correctly under Zod 4 (an optional
* record receiving `undefined` through `preprocess` errors with
* "expected nonoptional, received undefined").
*/
function coerceOptionalRecord(value: unknown): unknown {
if (value === undefined) return undefined;
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return {};
}
return value;
}
/**
* The probed module's default export shape. `hooks` and `routes` are
* both optional — a plugin that declares only one of them is valid.
* The top level is `looseObject` so additional keys on the default
* export (e.g. metadata fields the runtime doesn't use) don't fail the
* build; entries inside `hooks` / `routes` are validated strictly.
*/
export const ProbedDefaultSchema = z.looseObject({
hooks: z.preprocess(coerceOptionalRecord, z.record(z.string(), HookEntrySchema)).optional(),
routes: z.preprocess(coerceOptionalRecord, z.record(z.string(), RouteEntrySchema)).optional(),
});
export type ProbedDefault = z.infer<typeof ProbedDefaultSchema>;
export type ProbedHookEntry = z.infer<typeof HookEntrySchema>;
export type ProbedRouteEntry = z.infer<typeof RouteEntrySchema>;
+381
View File
@@ -0,0 +1,381 @@
/**
* Programmatic plugin-bundling API.
*
* Pure-ish core of the bundling pipeline — no `process.exit`, no console
* output. The CLI in `./command.ts` is a thin wrapper that turns these
* calls into pretty terminal output; tests exercise this module directly.
*
* Bundling is "build + validate + tarball". The build phase (probe,
* transpile, manifest extraction) lives in `../build/api.ts`. Bundle
* adds the publish-side concerns on top of build's output:
*
* 1. Run `buildPlugin` to produce `dist/manifest.json` (wire shape) and
* `dist/plugin.mjs` (runtime bytes).
* 2. Validate against publish constraints: no Node-builtin imports in
* the runtime, deprecated capabilities are still flagged, admin
* pages require an admin route, trusted-only features warn,
* bundle-size caps are honoured.
* 3. Stage the tarball contents in a temp dir, renaming `plugin.mjs`
* to `backend.js` (the registry's wire-side name).
* 4. Collect optional assets (README, icon, screenshots).
* 5. Gzip-tar the staging dir into `<outDir>/<id>-<version>.tar.gz`.
* 6. Compute sha256 and return.
*
* Failures throw `BundleError` with a structured `code` so callers can
* branch (CLI shows a helpful message; tests assert the code).
*/
import { createHash } from "node:crypto";
import { copyFile, mkdir, mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { extname, join, resolve } from "node:path";
import { buildPlugin, BuildError, type BuildLogger, type BuildResult } from "../build/api.js";
import { CAPABILITY_RENAMES, isDeprecatedCapability, type PluginManifest } from "./types.js";
import {
collectBundleEntries,
createTarball,
fileExists,
findNodeBuiltinImports,
formatBytes,
ICON_SIZE,
MAX_SCREENSHOTS,
MAX_SCREENSHOT_HEIGHT,
MAX_SCREENSHOT_WIDTH,
readImageDimensions,
totalBundleBytes,
validateBundleSize,
} from "./utils.js";
const SLASH_RE = /\//g;
const LEADING_AT_RE = /^@/;
// ──────────────────────────────────────────────────────────────────────────
// Public types
// ──────────────────────────────────────────────────────────────────────────
export type BundleErrorCode =
// Build-phase failures (passed through from BuildError).
| "MISSING_MANIFEST"
| "MISSING_PLUGIN_ENTRY"
| "MANIFEST_INVALID"
| "PACKAGE_JSON_INVALID"
| "VERSION_MISMATCH"
| "VERSION_MISSING"
| "RUNTIME_BUILD_FAILED"
| "PROBE_BUILD_FAILED"
| "INVALID_PLUGIN_FORMAT"
// Bundle-specific failures.
| "TRUSTED_ONLY_FEATURE"
| "VALIDATION_FAILED";
export class BundleError extends Error {
override readonly name = "BundleError";
readonly code: BundleErrorCode;
constructor(code: BundleErrorCode, message: string) {
super(message);
this.code = code;
}
}
export type BundleLogger = BuildLogger;
export interface BundleOptions {
/** Plugin source directory, must contain `package.json`. */
dir: string;
/**
* Output directory for the tarball, relative to `dir` if not absolute.
* Defaults to `<dir>/dist`.
*/
outDir?: string;
/**
* Skip tarball creation; only run the build + validation. Useful for
* pre-publish checks. Default: `false`.
*/
validateOnly?: boolean;
/** Optional progress reporter. */
logger?: BundleLogger;
}
export interface BundleResult {
/** The wire-shape plugin manifest (also written to `dist/manifest.json`). */
manifest: PluginManifest;
/** Absolute path to the resulting tarball, or `null` when `validateOnly`. */
tarballPath: string | null;
/** Tarball size in bytes, or `null` when `validateOnly`. */
tarballBytes: number | null;
/** Hex sha256 of the tarball contents, or `null` when `validateOnly`. */
sha256: string | null;
/** Non-fatal warnings collected during validation. */
warnings: string[];
}
// ──────────────────────────────────────────────────────────────────────────
// Implementation
// ──────────────────────────────────────────────────────────────────────────
export async function bundlePlugin(options: BundleOptions): Promise<BundleResult> {
const log = options.logger ?? {};
const pluginDir = resolve(options.dir);
const outDir = resolve(pluginDir, options.outDir ?? "dist");
const validateOnly = options.validateOnly ?? false;
const warnings: string[] = [];
const warn = (msg: string) => {
warnings.push(msg);
log.warn?.(msg);
};
log.start?.(validateOnly ? "Validating plugin..." : "Bundling plugin...");
// ── 1. Build dist/ via the shared pipeline ──
let build: BuildResult;
try {
build = await buildPlugin({ dir: pluginDir, outDir, logger: log });
} catch (error) {
if (error instanceof BuildError) {
throw new BundleError(error.code, error.message);
}
throw error;
}
const manifest = build.wireManifest;
const resolvedPlugin = build.resolvedPlugin;
log.success?.(`Plugin: ${manifest.id}@${manifest.version}`);
log.info?.(
` Capabilities: ${
manifest.capabilities.length > 0 ? manifest.capabilities.join(", ") : "(none)"
}`,
);
// ── 2. Stage tarball contents (rename plugin.mjs -> backend.js) ──
const tmpDir = await mkdtemp(join(tmpdir(), "emdash-bundle-"));
try {
const bundleDir = join(tmpDir, "bundle");
await mkdir(bundleDir, { recursive: true });
// Copy the runtime to `backend.js` (the registry's wire-side
// filename). The marketplace extractor + R2 keys all look for
// `backend.js`; the on-disk `dist/plugin.mjs` keeps a name that
// reads naturally in package.json exports.
await copyFile(build.files.runtime, join(bundleDir, "backend.js"));
// Copy the wire-shape manifest verbatim.
await copyFile(build.files.manifestJson, join(bundleDir, "manifest.json"));
// ── 3. Validate bundle contents ──
log.start?.("Validating bundle...");
const validationErrors: string[] = [];
// Node builtins in backend.js -> hard fail.
const backendCode = await readFile(join(bundleDir, "backend.js"), "utf-8");
const builtins = findNodeBuiltinImports(backendCode);
if (builtins.length > 0) {
validationErrors.push(
`backend.js imports Node.js built-in modules: ${builtins.join(", ")}. Sandboxed plugins cannot use Node.js APIs.`,
);
}
// Capability sanity warnings.
const declaresUnrestricted =
manifest.capabilities.includes("network:request:unrestricted") ||
manifest.capabilities.includes("network:fetch:any");
const declaresHostRestricted =
manifest.capabilities.includes("network:request") ||
manifest.capabilities.includes("network:fetch");
if (declaresUnrestricted) {
warn(
"Plugin declares unrestricted network access (network:request:unrestricted) — it can make requests to any host.",
);
} else if (declaresHostRestricted && manifest.allowedHosts.length === 0) {
// `publish` will hard-fail this case (INVALID_MANIFEST) because
// the lexicon says `request: {}` means "unrestricted" -- silently
// publishing that contradicts the apparent intent of declaring
// `network:request` (host-restricted) with empty allowedHosts.
// Surface it loudly at bundle time so the developer fixes it
// before they try to publish.
warn(
"Plugin declares network:request capability but no allowedHosts. The lexicon treats this as `unrestricted` access. Add specific host patterns to allowedHosts, or upgrade the capability to network:request:unrestricted. `publish` will refuse this combination.",
);
}
// Deprecated capabilities are warnings here; `publish` hard-fails on them.
const deprecatedCaps = manifest.capabilities.filter(isDeprecatedCapability);
if (deprecatedCaps.length > 0) {
warn("Plugin uses deprecated capability names. Rename them before publishing:");
for (const cap of deprecatedCaps) {
warn(` ${cap} -> ${CAPABILITY_RENAMES[cap]}`);
}
}
// Trusted-only features that won't work in sandboxed mode.
if (
resolvedPlugin.admin?.portableTextBlocks &&
resolvedPlugin.admin.portableTextBlocks.length > 0
) {
warn(
"Plugin declares portableTextBlocks — these require trusted mode and will be ignored in sandboxed plugins.",
);
}
if (resolvedPlugin.admin?.entry) {
warn(
"Plugin declares admin.entry — custom React components require trusted mode. Use Block Kit for sandboxed admin pages.",
);
}
if (resolvedPlugin.hooks["page:fragments"]) {
warn(
"Plugin declares page:fragments hook — this is trusted-only and will not work in sandboxed mode.",
);
}
// Admin pages/widgets require an `admin` route.
const hasAdminPages = (manifest.admin?.pages?.length ?? 0) > 0;
const hasAdminWidgets = (manifest.admin?.widgets?.length ?? 0) > 0;
if (hasAdminPages || hasAdminWidgets) {
const routeNames = manifest.routes.map((r) => (typeof r === "string" ? r : r.name));
if (!routeNames.includes("admin")) {
const declared =
hasAdminPages && hasAdminWidgets
? "adminPages and adminWidgets"
: hasAdminPages
? "adminPages"
: "adminWidgets";
validationErrors.push(
`Plugin declares ${declared} but the sandbox entry has no "admin" route. Add an admin route handler to serve Block Kit pages.`,
);
}
}
// ── 4. Collect optional assets ──
log.start?.("Collecting assets...");
await collectAssets({ pluginDir, bundleDir, log, warn });
// Bundle size caps (RFC 0001 §"Bundle size limits") — measured
// after assets are staged so README/icon/screenshots count.
const bundleEntries = await collectBundleEntries(bundleDir);
const sizeViolations = validateBundleSize(bundleEntries);
if (sizeViolations.length > 0) {
validationErrors.push(...sizeViolations);
} else {
log.info?.(
`Bundle size: ${formatBytes(totalBundleBytes(bundleEntries))} across ${bundleEntries.length} file${bundleEntries.length === 1 ? "" : "s"}`,
);
}
if (validationErrors.length > 0) {
throw new BundleError(
"VALIDATION_FAILED",
`Bundle validation failed:\n - ${validationErrors.join("\n - ")}`,
);
}
log.success?.("Validation passed");
// ── 5. Stop here if validateOnly ──
if (validateOnly) {
return {
manifest,
tarballPath: null,
tarballBytes: null,
sha256: null,
warnings,
};
}
// ── 6. Create tarball ──
await mkdir(outDir, { recursive: true });
const tarballName = `${manifest.id.replace(SLASH_RE, "-").replace(LEADING_AT_RE, "")}-${manifest.version}.tar.gz`;
const tarballPath = join(outDir, tarballName);
log.start?.("Creating tarball...");
await createTarball(bundleDir, tarballPath);
const tarballStat = await stat(tarballPath);
const tarballBuf = await readFile(tarballPath);
const sha256 = createHash("sha256").update(tarballBuf).digest("hex");
log.success?.(`Created ${tarballName} (${(tarballStat.size / 1024).toFixed(1)}KB)`);
log.info?.(` SHA-256: ${sha256}`);
log.info?.(` Path: ${tarballPath}`);
return {
manifest,
tarballPath,
tarballBytes: tarballStat.size,
sha256,
warnings,
};
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
}
// ──────────────────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────────────────
interface CollectAssetsContext {
pluginDir: string;
bundleDir: string;
log: BundleLogger;
warn: (msg: string) => void;
}
async function collectAssets(ctx: CollectAssetsContext): Promise<void> {
const { pluginDir, bundleDir, log, warn } = ctx;
const readmePath = join(pluginDir, "README.md");
if (await fileExists(readmePath)) {
await copyFile(readmePath, join(bundleDir, "README.md"));
log.success?.("Included README.md");
}
const iconPath = join(pluginDir, "icon.png");
if (await fileExists(iconPath)) {
const iconBuf = await readFile(iconPath);
const dims = readImageDimensions(iconBuf);
if (!dims) {
warn("icon.png is not a valid PNG — skipping");
} else {
if (dims[0] !== ICON_SIZE || dims[1] !== ICON_SIZE) {
warn(
`icon.png is ${dims[0]}x${dims[1]}, expected ${ICON_SIZE}x${ICON_SIZE} — including anyway`,
);
}
await copyFile(iconPath, join(bundleDir, "icon.png"));
log.success?.("Included icon.png");
}
}
const screenshotsDir = join(pluginDir, "screenshots");
if (await fileExists(screenshotsDir)) {
const screenshotFiles = (await readdir(screenshotsDir))
.filter((f) => {
const ext = extname(f).toLowerCase();
return ext === ".png" || ext === ".jpg" || ext === ".jpeg";
})
.toSorted()
.slice(0, MAX_SCREENSHOTS);
if (screenshotFiles.length > 0) {
await mkdir(join(bundleDir, "screenshots"), { recursive: true });
for (const file of screenshotFiles) {
const filePath = join(screenshotsDir, file);
const buf = await readFile(filePath);
const dims = readImageDimensions(buf);
if (!dims) {
warn(`screenshots/${file} — cannot read dimensions, skipping`);
continue;
}
if (dims[0] > MAX_SCREENSHOT_WIDTH || dims[1] > MAX_SCREENSHOT_HEIGHT) {
warn(
`screenshots/${file} is ${dims[0]}x${dims[1]}, max ${MAX_SCREENSHOT_WIDTH}x${MAX_SCREENSHOT_HEIGHT} — including anyway`,
);
}
await copyFile(filePath, join(bundleDir, "screenshots", file));
}
log.success?.(`Included ${screenshotFiles.length} screenshot(s)`);
}
}
}
+83
View File
@@ -0,0 +1,83 @@
/**
* `emdash-plugin bundle`
*
* Thin citty wrapper around `bundlePlugin` from `./api.js`. The interesting
* logic lives there; this file only handles arg parsing, consola formatting,
* and process exit on errors so the rest of the CLI can `await` it cleanly.
*
* If you're building tooling on top of bundling, import `bundlePlugin`
* directly -- this command is the terminal-output adapter, not the API.
*/
import { defineCommand } from "citty";
import consola from "consola";
import pc from "picocolors";
import { BundleError, bundlePlugin, type BundleLogger } from "./api.js";
export const bundleCommand = defineCommand({
meta: {
name: "bundle",
description: "Bundle a plugin for marketplace distribution",
},
args: {
dir: {
type: "string",
description: "Plugin directory (default: current directory)",
default: process.cwd(),
},
"out-dir": {
type: "string",
alias: "o",
description: "Output directory for the tarball (default: ./dist)",
default: "dist",
},
"validate-only": {
type: "boolean",
description: "Run validation only, skip tarball creation",
default: false,
},
},
async run({ args }) {
const logger: BundleLogger = {
start: (m) => consola.start(m),
info: (m) => consola.info(m),
success: (m) => consola.success(m),
warn: (m) => consola.warn(m),
};
let result;
try {
result = await bundlePlugin({
dir: args.dir,
outDir: args["out-dir"],
validateOnly: args["validate-only"],
logger,
});
} catch (error) {
if (error instanceof BundleError) {
consola.error(error.message);
process.exit(1);
}
throw error;
}
// Bundling and publishing are two steps with a "go upload this somewhere"
// gap between them — the registry never accepts uploads, the publisher
// hosts the artifact (GitHub release asset, R2, S3, their own server)
// and the registry indexes the URL. Spell out the next step so users
// don't have to dig for it.
if (!args["validate-only"] && result.tarballPath) {
console.log();
consola.info("Next steps:");
console.log(` 1. Upload ${pc.cyan(result.tarballPath)} to a public URL.`);
console.log(
` 2. Publish the release record:\n` +
` ${pc.cyan(`emdash-plugin publish --url <hosted-url>`)}`,
);
console.log(
` ${pc.dim(`(or pass --local ${result.tarballPath} to verify the URL serves matching bytes before publishing)`)}`,
);
}
},
});
+72
View File
@@ -0,0 +1,72 @@
/**
* Local-only types for the bundling pipeline.
*
* The cross-package manifest contract (capabilities, manifest shape, hook
* entries, etc.) lives in `@emdash-cms/plugin-types` and is re-exported from
* here for convenience so the bundling internals only need one import.
*
* The only type that doesn't go upstream is `ResolvedPlugin`: it's the
* loose internal shape the bundler reads from a user plugin's compiled
* `createPlugin()` / descriptor-factory output. Core has its own canonical,
* tightly-typed `ResolvedPlugin` that we don't want to depend on (it pulls
* in Astro / blocks / schema). Both sides exist in service of the same
* manifest contract; they don't need to share the runtime shape.
*/
export {
CAPABILITY_RENAMES,
capabilitiesToDeclaredAccess,
declaredAccessToCapabilities,
isDeprecatedCapability,
type CurrentPluginCapability,
type DeclaredAccess,
type DeprecatedPluginCapability,
type ManifestHookEntry,
type ManifestRouteEntry,
type PluginAdminConfig,
type PluginCapability,
type PluginManifest,
type PluginStorageConfig,
type StorageCollectionConfig,
} from "@emdash-cms/plugin-types";
import type {
PluginAdminConfig,
PluginCapability,
PluginStorageConfig,
} from "@emdash-cms/plugin-types";
/**
* The bundler's view of a "resolved" plugin -- whatever the user's plugin
* module exposes after we build + import it. Loose by design: the third-party
* code we're loading may follow several formats (native `createPlugin`,
* descriptor factory, default object), and we extract a manifest from any of
* them. Treat field absence as "not declared".
*/
export interface ResolvedPlugin {
id: string;
version: string;
capabilities: PluginCapability[];
allowedHosts: string[];
storage: PluginStorageConfig;
hooks: Record<
string,
{
handler?: unknown;
priority?: number;
timeout?: number;
dependencies?: string[];
errorPolicy?: string;
exclusive?: boolean;
pluginId?: string;
}
>;
routes: Record<
string,
{
handler?: unknown;
public?: boolean;
}
>;
admin: PluginAdminConfig;
}
+355
View File
@@ -0,0 +1,355 @@
/**
* Bundle utility functions.
*
* COPIED from `packages/core/src/cli/commands/bundle-utils.ts`. Kept in sync
* with the legacy core copy until phase 1 cutover, when the legacy copy
* goes away. Logic is unchanged; only the type imports point at the local
* `./types.js` instead of core's plugin types.
*/
import { createWriteStream } from "node:fs";
import { access, readdir, stat } from "node:fs/promises";
import { join, resolve } from "node:path";
import { pipeline } from "node:stream/promises";
import { imageSize } from "image-size";
import { packTar } from "modern-tar/fs";
import { capabilitiesToDeclaredAccess } from "./types.js";
import type { ManifestHookEntry, PluginManifest, ResolvedPlugin } from "./types.js";
// ── Constants ────────────────────────────────────────────────────────────────
// Bundle size caps per RFC 0001 §"Bundle size limits". These are decompressed
// sizes; the gzipped tarball is typically a fraction of MAX_BUNDLE_SIZE.
export const MAX_BUNDLE_SIZE = 256 * 1024;
export const MAX_FILE_SIZE = 128 * 1024;
export const MAX_FILE_COUNT = 20;
export const MAX_SCREENSHOTS = 8;
export const MAX_SCREENSHOT_WIDTH = 1920;
export const MAX_SCREENSHOT_HEIGHT = 1080;
export const ICON_SIZE = 256;
// ── Regex patterns (module-scope to avoid re-compilation) ────────────────────
/**
* Matches Node.js built-in imports in bundled output.
* Captures the base module name (e.g. "fs" from "node:fs/promises").
*/
const NODE_BUILTIN_IMPORT_RE =
/(?:import|export|require)\s*(?:\(|[^(]*?\bfrom\s+)["'](?:node:)?([a-z_]+)(?:\/[^"']*)?\s*["']\)?/g;
const LEADING_DOT_SLASH_RE = /^\.\//;
const DIST_PREFIX_RE = /^dist\//;
const MJS_EXT_RE = /\.m?js$/;
const TS_TO_TSX_RE = /\.ts$/;
/** Node.js built-in modules that shouldn't appear in sandbox code. */
const NODE_BUILTINS = new Set([
"assert",
"buffer",
"child_process",
"cluster",
"crypto",
"dgram",
"dns",
"domain",
"events",
"fs",
"http",
"http2",
"https",
"inspector",
"module",
"net",
"os",
"path",
"perf_hooks",
"process",
"punycode",
"querystring",
"readline",
"repl",
"stream",
"string_decoder",
"sys",
"timers",
"tls",
"trace_events",
"tty",
"url",
"util",
"v8",
"vm",
"wasi",
"worker_threads",
"zlib",
]);
// ── File helpers ─────────────────────────────────────────────────────────────
export async function fileExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
// ── Image dimension readers ──────────────────────────────────────────────────
/**
* Read image dimensions from a buffer.
* Returns `[width, height]` or null if the format is unrecognized.
*/
export function readImageDimensions(buf: Uint8Array): [number, number] | null {
try {
const result = imageSize(buf);
if (result.width != null && result.height != null) {
return [result.width, result.height];
}
return null;
} catch {
return null;
}
}
// ── Manifest extraction ──────────────────────────────────────────────────────
/**
* Extract serialisable manifest metadata from a ResolvedPlugin.
* Strips functions (hooks, route handlers) and keeps only the
* publish-relevant fields.
*/
export function extractManifest(plugin: ResolvedPlugin): PluginManifest {
const hooks: Array<ManifestHookEntry | string> = [];
for (const [name, resolved] of Object.entries(plugin.hooks)) {
if (!resolved) continue;
const hasMetadata =
resolved.exclusive ||
(resolved.priority !== undefined && resolved.priority !== 100) ||
(resolved.timeout !== undefined && resolved.timeout !== 5000);
if (hasMetadata) {
const entry: ManifestHookEntry = { name };
if (resolved.exclusive) entry.exclusive = true;
if (resolved.priority !== undefined && resolved.priority !== 100) {
entry.priority = resolved.priority;
}
if (resolved.timeout !== undefined && resolved.timeout !== 5000) {
entry.timeout = resolved.timeout;
}
hooks.push(entry);
} else {
hooks.push(name);
}
}
return {
id: plugin.id,
version: plugin.version,
declaredAccess: capabilitiesToDeclaredAccess(plugin.capabilities, plugin.allowedHosts),
capabilities: plugin.capabilities,
allowedHosts: plugin.allowedHosts,
storage: plugin.storage,
hooks,
routes: Object.keys(plugin.routes),
admin: {
// Omit `entry` (it's a module specifier for the host, not relevant in bundles)
settingsSchema: plugin.admin.settingsSchema,
pages: plugin.admin.pages,
widgets: plugin.admin.widgets,
},
};
}
// ── Node.js built-in detection ───────────────────────────────────────────────
/**
* Scan bundled code for Node.js built-in imports.
* Matches patterns that appear in bundled ESM/CJS output (not source-level
* named imports). Returns deduplicated array of built-in module names found.
*/
export function findNodeBuiltinImports(code: string): string[] {
const found: string[] = [];
NODE_BUILTIN_IMPORT_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = NODE_BUILTIN_IMPORT_RE.exec(code)) !== null) {
const mod = match[1];
if (mod && NODE_BUILTINS.has(mod)) {
found.push(mod);
}
}
return [...new Set(found)];
}
// ── Path resolution ──────────────────────────────────────────────────────────
/**
* Find a build output file by base name, checking common extensions.
* tsdown may output .mjs, .js, or .cjs depending on format and config.
*/
export async function findBuildOutput(dir: string, baseName: string): Promise<string | undefined> {
for (const ext of [".mjs", ".js", ".cjs"]) {
const candidate = join(dir, `${baseName}${ext}`);
if (await fileExists(candidate)) return candidate;
}
return undefined;
}
/**
* Resolve a dist/built path back to its source `.ts`/`.tsx` equivalent.
* E.g. `./dist/index.mjs` → `src/index.ts`.
*/
export async function resolveSourceEntry(
pluginDir: string,
distPath: string,
): Promise<string | undefined> {
const cleaned = distPath.replace(LEADING_DOT_SLASH_RE, "");
const srcPath = cleaned.replace(DIST_PREFIX_RE, "src/").replace(MJS_EXT_RE, ".ts");
const srcFull = resolve(pluginDir, srcPath);
if (await fileExists(srcFull)) return srcFull;
const tsxPath = srcPath.replace(TS_TO_TSX_RE, ".tsx");
const tsxFull = resolve(pluginDir, tsxPath);
if (await fileExists(tsxFull)) return tsxFull;
const direct = resolve(pluginDir, cleaned);
if (await fileExists(direct)) return direct;
return undefined;
}
// ── Export validation ────────────────────────────────────────────────────────
const TS_SOURCE_EXPORT_RE = /\.(?:ts|tsx|mts|cts|jsx)$/;
/**
* Find `package.json` exports that point to source files instead of built
* output. Source exports break sandbox publishing because the sandbox module
* generator embeds the resolved file as-is.
*/
export function findSourceExports(
exports: Record<string, unknown>,
): Array<{ exportPath: string; resolvedPath: string }> {
const issues: Array<{ exportPath: string; resolvedPath: string }> = [];
for (const [exportPath, exportValue] of Object.entries(exports)) {
const resolvedPath =
typeof exportValue === "string"
? exportValue
: exportValue && typeof exportValue === "object" && "import" in exportValue
? typeof exportValue.import === "string"
? (exportValue as { import: string }).import
: null
: null;
if (resolvedPath && TS_SOURCE_EXPORT_RE.test(resolvedPath)) {
issues.push({ exportPath, resolvedPath });
}
}
return issues;
}
// ── Directory helpers ────────────────────────────────────────────────────────
/**
* One file in a bundle: a tarball-relative path and its byte length.
* Produced by `collectBundleEntries` (from a staging dir) or by the publish
* flow (from tarball entries); consumed by `validateBundleSize`.
*/
export interface BundleFileEntry {
name: string;
bytes: number;
}
/**
* Recursively walk a staging directory and return a flat list of all files
* with sizes. Names are relative to `dir` so they match what would appear
* as the tarball entry name.
*/
export async function collectBundleEntries(dir: string): Promise<BundleFileEntry[]> {
const entries: BundleFileEntry[] = [];
await walkBundle(dir, "", entries);
return entries;
}
async function walkBundle(dir: string, prefix: string, into: BundleFileEntry[]): Promise<void> {
const items = await readdir(dir, { withFileTypes: true });
for (const item of items) {
const fullPath = join(dir, item.name);
const relPath = prefix ? `${prefix}/${item.name}` : item.name;
if (item.isFile()) {
const s = await stat(fullPath);
into.push({ name: relPath, bytes: s.size });
} else if (item.isDirectory()) {
await walkBundle(fullPath, relPath, into);
}
}
}
/**
* Sum the byte sizes of all entries.
*/
export function totalBundleBytes(entries: readonly BundleFileEntry[]): number {
let total = 0;
for (const e of entries) total += e.bytes;
return total;
}
/**
* Check a bundle against the three size caps from RFC 0001:
* - total decompressed ≤ MAX_BUNDLE_SIZE
* - per-file decompressed ≤ MAX_FILE_SIZE
* - file count ≤ MAX_FILE_COUNT
*
* Returns a list of violation messages (empty if the bundle is within all
* caps). Messages are deterministic per input — the total/count violations
* come first, then oversized files in alphabetical order — so the same
* bundle always produces the same error text.
*/
export function validateBundleSize(entries: readonly BundleFileEntry[]): string[] {
const violations: string[] = [];
const total = totalBundleBytes(entries);
if (total > MAX_BUNDLE_SIZE) {
violations.push(
`Bundle size ${formatBytes(total)} exceeds maximum of ${formatBytes(MAX_BUNDLE_SIZE)}.`,
);
}
if (entries.length > MAX_FILE_COUNT) {
violations.push(
`Bundle contains ${entries.length} files, exceeds maximum of ${MAX_FILE_COUNT}.`,
);
}
const oversized = entries
.filter((e) => e.bytes > MAX_FILE_SIZE)
.toSorted((a, b) => a.name.localeCompare(b.name));
for (const e of oversized) {
violations.push(
`File ${e.name} is ${formatBytes(e.bytes)}, exceeds per-file maximum of ${formatBytes(MAX_FILE_SIZE)}.`,
);
}
return violations;
}
/**
* Render a byte count as a human-friendly string. Mirrors the format used
* by the publish CLI's user-facing error messages (e.g. "256.0 KB").
*/
export function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(2)} MB`;
}
// ── Tarball creation ─────────────────────────────────────────────────────────
/**
* Create a gzipped tarball from a directory.
*/
export async function createTarball(sourceDir: string, outputPath: string): Promise<void> {
const { createGzip } = await import("node:zlib");
const tarStream = packTar(sourceDir);
const gzip = createGzip({ level: 9 });
const out = createWriteStream(outputPath);
await pipeline(tarStream, gzip, out);
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Shared CLI output helpers used by multiple commands. Lives here so a
* change to the JSON-mode contract (e.g. tag/level formatting on stderr)
* doesn't have to be touched in every command file.
*/
import consola from "consola";
/**
* Reroute every consola log call to stderr. Used by `--json` mode so the
* structured JSON object on stdout is the only thing a pipe consumer sees.
*
* Returns a restore function that puts the previous reporter set back. The
* caller invokes it in a finally block so a wrapper script that runs a
* command in-process and then continues with other commands gets its
* consola back.
*
* We replace the global reporter (rather than constructing a separate
* instance) so downstream helpers that import the default `consola`
* singleton are also redirected.
*/
export function redirectConsolaToStderr(): () => void {
const previous = consola.options.reporters?.slice() ?? [];
consola.setReporters([
{
log(logObj) {
const level = logObj.type ?? "info";
const tag = logObj.tag ? `[${logObj.tag}] ` : "";
const args = logObj.args ?? [];
const message = args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ");
process.stderr.write(`${level}: ${tag}${message}\n`);
},
},
]);
return () => {
consola.setReporters(previous);
};
}
+105
View File
@@ -0,0 +1,105 @@
/**
* `emdash-plugin info <handle-or-did> <slug>`
*
* Show details about a single package. Read-only; no auth required.
*
* The first positional argument can be either a handle (`alice.example.com`)
* or a DID (`did:plc:abc...`). The aggregator distinguishes via separate XRPC
* methods -- handle goes through `resolvePackage` (which does the
* handle-to-DID lookup server-side), DID goes straight to `getPackage`.
*/
import { isDid, isHandle } from "@atcute/lexicons/syntax";
import { DiscoveryClient } from "@emdash-cms/registry-client";
import { defineCommand } from "citty";
import { consola } from "consola";
import pc from "picocolors";
import { resolveAggregatorUrl } from "../config.js";
export const infoCommand = defineCommand({
meta: {
name: "info",
description: "Show details about a single package",
},
args: {
publisher: {
type: "positional",
description: "Publisher handle (e.g. alice.example.com) or DID",
required: true,
},
slug: {
type: "positional",
description: "Package slug",
required: true,
},
"registry-url": {
type: "string",
description: "Override registry URL",
},
json: {
type: "boolean",
description: "Output as JSON",
},
},
async run({ args }) {
const aggregatorUrl = resolveAggregatorUrl(args["registry-url"]);
const client = new DiscoveryClient({ aggregatorUrl });
let result;
if (isDid(args.publisher)) {
result = await client.getPackage({ did: args.publisher, slug: args.slug });
} else if (isHandle(args.publisher)) {
result = await client.resolvePackage({
handle: args.publisher,
slug: args.slug,
});
} else {
consola.error(
`"${args.publisher}" is not a valid handle or DID. Expected a handle like "alice.example.com" or a DID like "did:plc:abc123..."`,
);
process.exit(2);
}
if (args.json) {
console.log(JSON.stringify(result, null, 2));
return;
}
// `result.profile` is validated against the package profile lexicon by
// DiscoveryClient (the read-side trust boundary), or `null` when the
// aggregator returned a non-conforming record.
const profile = result.profile;
if (!profile) {
consola.warn(
`Profile record at ${result.uri} doesn't match the lexicon; showing the slug only.`,
);
}
const name = profile?.name ?? result.slug;
const description = profile?.description;
const license = profile?.license;
console.log();
console.log(pc.bold(name));
if (description) {
console.log(description);
}
console.log();
console.log(` Slug: ${result.slug}`);
console.log(` Publisher: ${result.handle ?? result.did}`);
console.log(` License: ${license ?? "unknown"}`);
if (result.latestVersion) {
console.log(` Latest: ${result.latestVersion}`);
}
console.log(` AT URI: ${pc.dim(result.uri)}`);
console.log();
if (result.labels && result.labels.length > 0) {
consola.info(`Labels (${result.labels.length}):`);
for (const label of result.labels) {
console.log(` ${pc.yellow(label.val)} ${pc.dim(`(by ${label.src})`)}`);
}
}
},
});
+656
View File
@@ -0,0 +1,656 @@
/**
* `emdash-plugin init [name]`
*
* Scaffold a new sandboxed plugin. Produces the three-file authoring
* contract (manifest + src/plugin.ts + package.json) plus tsconfig,
* README, .gitignore, and a passing test.
*
* Three modes:
*
* 1. Interactive (default on a TTY): clack prompts for each unset
* field with sensible defaults. ESC / Ctrl+C cancels cleanly.
* 2. `--yes` / `-y` (non-interactive): no prompts; unset fields
* become TODO placeholders in the generated manifest. The user
* fixes them before first use.
* 3. Non-TTY (CI, pipes): same as `--yes`. Prompting into a
* non-interactive stdin would hang.
*
* In all modes, explicit flags win — they're treated as final answers
* and skip the prompt for that field.
*
* Exit codes:
* 0 — scaffold written.
* 1 — input validation failed, target conflict (without --force),
* prompt cancelled, or filesystem error.
*/
import { basename, resolve } from "node:path";
import { isDid, isHandle } from "@atcute/lexicons/syntax";
import * as clack from "@clack/prompts";
import { isPluginSlug } from "@emdash-cms/plugin-types";
import { FileCredentialStore } from "@emdash-cms/registry-client";
import { defineCommand } from "citty";
import consola from "consola";
import pc from "picocolors";
import { probeEnvironment, type EnvironmentDefaults } from "../init/environment.js";
import { InitError, scaffold } from "../init/scaffold.js";
import type { ScaffoldInputs } from "../init/templates.js";
import { PublisherCheckError, resolveHandleToDid } from "../manifest/publisher.js";
export const initCommand = defineCommand({
meta: {
name: "init",
description:
"Scaffold a new sandboxed plugin: emdash-plugin.jsonc, src/plugin.ts, package.json, tests, and a README.",
},
args: {
name: {
type: "positional",
required: false,
description:
"Plugin slug. Used as the directory name and the manifest's `slug` field. If omitted, the slug is derived from the current directory name (or prompted in interactive mode).",
},
dir: {
type: "string",
description:
"Target directory. Defaults to ./<name> when `name` is given, or the current directory when it isn't.",
},
publisher: {
type: "string",
description:
"Atproto handle or DID. In interactive mode this is prompted; in --yes mode an unset value becomes a TODO placeholder.",
},
license: {
type: "string",
description: 'SPDX license expression. Defaults to "MIT".',
},
"author-name": {
type: "string",
description: "Author name.",
},
"author-url": {
type: "string",
description: "Author URL.",
},
"author-email": {
type: "string",
description: "Author email.",
},
"security-email": {
type: "string",
description:
"Security contact email. Either --security-email or --security-url should be set; in --yes mode an unset value becomes a TODO placeholder.",
},
"security-url": {
type: "string",
description: "Security contact URL.",
},
description: {
type: "string",
description: "Short plugin description (omitted from the manifest if not provided).",
},
repo: {
type: "string",
description: "Source repository URL (omitted from the manifest if not provided).",
},
yes: {
type: "boolean",
alias: "y",
description:
"Skip interactive prompts. Unset fields become TODO placeholders in the manifest. Automatically enabled when stdin is not a TTY.",
default: false,
},
force: {
type: "boolean",
description:
"Overwrite existing files in the target directory. Without this flag, init refuses if any target file already exists.",
default: false,
},
},
async run({ args }) {
try {
await runInit(args);
} catch (error) {
if (error instanceof InitError || error instanceof InputError) {
consola.error(error.message);
process.exit(1);
}
throw error;
}
},
});
interface InitArgs {
name?: string;
dir?: string;
publisher?: string;
license?: string;
"author-name"?: string;
"author-url"?: string;
"author-email"?: string;
"security-email"?: string;
"security-url"?: string;
description?: string;
repo?: string;
yes?: boolean;
force?: boolean;
}
async function runInit(args: InitArgs): Promise<void> {
// Non-TTY stdin → can't prompt; behave as if --yes were passed.
// stdout being a pipe is fine (we still write progress); it's the
// input side that has to be a terminal for prompts to work.
const interactive = !(args.yes ?? false) && process.stdin.isTTY === true;
if (interactive) clack.intro(pc.bold("emdash-plugin init"));
// Load the active session (if any). Used to pre-fill the publisher
// prompt and to silently fill it in `--yes` mode. We swallow load
// errors entirely — init is reachable from a fresh checkout where
// the credentials store doesn't exist yet, and a corrupt-store
// failure should not block scaffolding.
const session = await loadCurrentSessionSilently();
// Resolve slug + target dir. Slug may come from positional, --dir's
// basename, cwd's basename, or (interactive only) a prompt.
let { slug, targetDir } = resolveSlugAndDir(args);
if (nonEmpty(args.name) === undefined && nonEmpty(args.dir) === undefined && interactive) {
const answer = await clack.text({
message: "Plugin slug",
placeholder: "my-plugin",
defaultValue: slug,
});
assertNotCancelled(answer);
if (typeof answer === "string" && answer.trim().length > 0) {
slug = answer.trim();
targetDir = resolve(`./${slug}`);
}
}
if (!isPluginSlug(slug)) {
throw new InputError(
`Slug "${slug}" is not a valid plugin slug. Expected: lowercase letter, then lowercase letters / digits / "-" / "_" (max 64 chars).`,
);
}
// Probe the surrounding environment for pre-fillable defaults
// (git user.name / user.email, git remote URL, package.json fields).
// Probe the target dir if it exists, otherwise cwd — that covers
// both "init into existing repo skeleton" and "init alongside the
// current project" workflows. Failures inside the probe are
// swallowed; missing fields stay undefined.
const env = await probeEnvironment(await pickProbeDir(targetDir));
const publisherResult = await resolvePublisher(args, interactive, session);
const license = await resolveLicense(args, interactive, env);
const author = await resolveAuthor(args, interactive, env);
const security = await resolveSecurity(args, interactive);
const description = await resolveDescription(args, interactive, env);
const repo = await resolveRepo(args, interactive, env);
const inputs: ScaffoldInputs = {
slug,
publisher: publisherResult?.did,
publisherHandle: publisherResult?.handle,
license,
author,
security,
description,
repo,
};
const spin = interactive ? clack.spinner() : null;
spin?.start(`Scaffolding ${slug} in ${targetDir}`);
let result;
try {
result = await scaffold({
targetDir,
inputs,
force: args.force ?? false,
onFileWritten: interactive
? undefined
: (relPath) => consola.info(` ${pc.green("+")} ${relPath}`),
});
} catch (error) {
// `error()` on the spinner reports the failure with the right
// glyph; the outer dispatch handles the actual exit code.
spin?.error("Scaffold failed");
throw error;
}
spin?.stop(`Scaffolded ${result.written.length} files`);
if (!interactive) {
consola.success(`Scaffolded ${result.written.length} files in ${targetDir}`);
}
printNextSteps(targetDir, inputs, interactive);
}
// ──────────────────────────────────────────────────────────────────────────
// Per-field resolvers. Each consults the flag first; falls through to a
// clack prompt in interactive mode; falls through to `undefined` (→ the
// template emits a TODO) in non-interactive mode.
// ──────────────────────────────────────────────────────────────────────────
/**
* The publisher resolution result. We always write a DID to the manifest
* (the runtime compares DIDs), but if the user typed a handle (or had
* one from their active session) we carry it through so the rendered
* manifest can emit a `// <handle>` comment next to the pinned DID.
*/
interface PublisherResult {
did: string;
handle: string | undefined;
}
/**
* Resolve the publisher to write into the manifest. Precedence:
*
* 1. `--publisher` flag (handle or DID; resolved to DID if a handle).
* 2. In `--yes` / non-TTY mode: the active session's handle/DID.
* 3. In interactive mode: a prompt pre-filled with the active session's
* handle (if logged in).
* 4. Otherwise: undefined → manifest gets a TODO placeholder.
*
* For user-typed handles, we eagerly resolve to a DID. The runtime only
* cares about the DID; writing it now means the post-publish write-back
* isn't needed for handle→DID conversion later.
*/
async function resolvePublisher(
args: InitArgs,
interactive: boolean,
session: SessionInfo | undefined,
): Promise<PublisherResult | undefined> {
const flag = nonEmpty(args.publisher);
if (flag !== undefined) {
return await resolvePublisherInput(flag, "--publisher");
}
// --yes / non-TTY with an active session: silently fill from session.
// The user can override by passing --publisher; we only reach here
// when they didn't.
if (!interactive) {
if (session) return { did: session.did, handle: session.handle ?? undefined };
return undefined;
}
const placeholder = session?.handle ?? "example.com";
const defaultValue = session?.handle ?? undefined;
const answer = await clack.text({
message: session
? "Atproto publisher (press enter to use your logged-in handle, or type a handle / DID)"
: "Atproto publisher (handle or DID, leave blank to fill in later)",
placeholder,
...(defaultValue !== undefined && { defaultValue }),
validate: (raw) => {
// clack 1.x types `raw` as `string | undefined` because the
// user can submit without typing anything. Treat that as
// "blank, fine — user wants to fill it in later".
const v = (raw ?? "").trim();
if (v.length === 0) return undefined;
if (isDid(v) || isHandle(v)) return undefined;
return 'Must be a handle (e.g. "example.com") or DID (e.g. "did:plc:...").';
},
});
assertNotCancelled(answer);
const value = typeof answer === "string" ? answer.trim() : "";
if (value.length === 0) return undefined;
return await resolvePublisherInput(value, "publisher");
}
/**
* Turn a raw publisher input (handle or DID) into a `PublisherResult`.
* DIDs pass through verbatim with no handle. Handles round-trip through
* the atproto resolver to produce a DID; the original handle is carried
* for the manifest comment.
*
* `sourceLabel` is used in error messages to disambiguate "the
* --publisher flag" from "the prompt".
*/
async function resolvePublisherInput(input: string, sourceLabel: string): Promise<PublisherResult> {
if (isDid(input)) {
return { did: input, handle: undefined };
}
if (!isHandle(input)) {
throw new InputError(
`${sourceLabel} "${input}" is not a valid atproto handle or DID. Expected a handle (e.g. "example.com") or DID (e.g. "did:plc:abc...").`,
);
}
try {
const did = await resolveHandleToDid(input);
return { did, handle: input };
} catch (error) {
if (error instanceof PublisherCheckError) {
throw new InputError(error.message);
}
throw error;
}
}
async function resolveLicense(
args: InitArgs,
interactive: boolean,
env: EnvironmentDefaults,
): Promise<string | undefined> {
const flag = nonEmpty(args.license);
if (flag !== undefined) return flag;
// --yes / non-TTY: take whatever the environment told us, fall
// through to undefined (template defaults to "MIT").
if (!interactive) return env.license;
const defaultValue = env.license ?? "MIT";
const answer = await clack.text({
message: "License (SPDX expression)",
defaultValue,
placeholder: defaultValue,
});
assertNotCancelled(answer);
const value = typeof answer === "string" ? answer.trim() : "";
return value.length === 0 ? undefined : value;
}
async function resolveAuthor(args: InitArgs, interactive: boolean, env: EnvironmentDefaults) {
const flagName = nonEmpty(args["author-name"]);
const flagUrl = nonEmpty(args["author-url"]);
const flagEmail = nonEmpty(args["author-email"]);
if (flagName !== undefined || flagUrl !== undefined || flagEmail !== undefined) {
// Any author flag set → assemble what we have. Missing sub-fields
// stay undefined; the template only emits the ones that are set.
// Fall back to environment values for the unset sub-fields so
// the user gets a complete author block when their git config
// has the info.
return {
name: flagName ?? env.authorName ?? "TODO: replace with your name",
...((flagUrl ?? undefined) !== undefined && { url: flagUrl! }),
...((flagEmail ?? env.authorEmail) !== undefined && {
email: flagEmail ?? env.authorEmail!,
}),
};
}
// --yes / non-TTY: use environment defaults only. If git config has
// both name and email, scaffolding picks them up silently.
if (!interactive) {
if (env.authorName === undefined && env.authorEmail === undefined) {
return undefined;
}
return {
name: env.authorName ?? "TODO: replace with your name",
...(env.authorEmail !== undefined && { email: env.authorEmail }),
};
}
const nameAns = await clack.text({
message: env.authorName
? "Author name (press enter to use your git config)"
: "Author name (leave blank to fill in later)",
...(env.authorName !== undefined && { defaultValue: env.authorName }),
placeholder: env.authorName ?? "Jane Doe",
});
assertNotCancelled(nameAns);
const name = stringOrEmpty(nameAns);
if (name.length === 0) return undefined;
const urlAns = await clack.text({
message: "Author URL (optional)",
});
assertNotCancelled(urlAns);
const url = stringOrEmpty(urlAns);
const emailAns = await clack.text({
message: env.authorEmail
? "Author email (press enter to use your git config)"
: "Author email (optional)",
...(env.authorEmail !== undefined && { defaultValue: env.authorEmail }),
placeholder: env.authorEmail ?? "jane@example.com",
});
assertNotCancelled(emailAns);
const email = stringOrEmpty(emailAns);
return {
name,
...(url.length > 0 && { url }),
...(email.length > 0 && { email }),
};
}
async function resolveDescription(
args: InitArgs,
interactive: boolean,
env: EnvironmentDefaults,
): Promise<string | undefined> {
const flag = nonEmpty(args.description);
if (flag !== undefined) return flag;
if (!interactive) return env.description;
const answer = await clack.text({
message: env.description
? "Short description (press enter to use package.json#description)"
: "Short description (optional)",
...(env.description !== undefined && { defaultValue: env.description }),
placeholder: env.description ?? "What does the plugin do?",
});
assertNotCancelled(answer);
const value = stringOrEmpty(answer);
return value.length === 0 ? undefined : value;
}
async function resolveRepo(
args: InitArgs,
interactive: boolean,
env: EnvironmentDefaults,
): Promise<string | undefined> {
const flag = nonEmpty(args.repo);
if (flag !== undefined) return flag;
if (!interactive) return env.repo;
const answer = await clack.text({
message: env.repo
? "Source repository URL (press enter to use the detected origin)"
: "Source repository URL (optional)",
...(env.repo !== undefined && { defaultValue: env.repo }),
placeholder: env.repo ?? "https://github.com/...",
validate: (raw) => {
const v = (raw ?? "").trim();
if (v.length === 0) return undefined;
if (!v.startsWith("https://")) return "Must start with https://";
return undefined;
},
});
assertNotCancelled(answer);
const value = stringOrEmpty(answer);
return value.length === 0 ? undefined : value;
}
async function resolveSecurity(args: InitArgs, interactive: boolean) {
const flagEmail = nonEmpty(args["security-email"]);
const flagUrl = nonEmpty(args["security-url"]);
if (flagEmail !== undefined || flagUrl !== undefined) {
return {
...(flagEmail !== undefined && { email: flagEmail }),
...(flagUrl !== undefined && { url: flagUrl }),
};
}
if (!interactive) return undefined;
const emailAns = await clack.text({
message: "Security contact email (leave blank to provide a URL or fill in later)",
});
assertNotCancelled(emailAns);
const email = stringOrEmpty(emailAns);
if (email.length > 0) return { email };
const urlAns = await clack.text({
message: "Security contact URL (leave blank to fill in later)",
});
assertNotCancelled(urlAns);
const url = stringOrEmpty(urlAns);
if (url.length === 0) return undefined;
return { url };
}
// ──────────────────────────────────────────────────────────────────────────
// Session pre-fill
// ──────────────────────────────────────────────────────────────────────────
/**
* The slice of the active session init cares about. Pulled out so the
* session-loading helper can return a plain shape without dragging the
* full StoredSession type through the rest of the command.
*/
interface SessionInfo {
did: string;
handle: string | null;
}
/**
* Choose where to run environment probes against:
*
* - target dir if it exists (already a git repo with a package.json,
* scaffolding into it),
* - cwd otherwise (init creating a new sibling dir).
*
* Picking the target lets us read package.json#description / #license
* for the "scaffold into existing repo" case; falling back to cwd
* still gets us git user.name/user.email which live in the global
* config and don't depend on which dir we run from.
*/
async function pickProbeDir(targetDir: string): Promise<string> {
const { stat } = await import("node:fs/promises");
try {
const info = await stat(targetDir);
if (info.isDirectory()) return targetDir;
} catch {
// Target dir doesn't exist yet — that's the common case for
// `init my-plugin`. Fall through to cwd.
}
return process.cwd();
}
/**
* Load the active publisher session from the on-disk credentials store.
* Returns `undefined` on every failure path — the credentials file
* doesn't exist (fresh checkout), is corrupted, contains no current
* session, etc. init is reachable in all these states; we never want
* scaffolding to be blocked by a session lookup.
*/
async function loadCurrentSessionSilently(): Promise<SessionInfo | undefined> {
try {
const credentials = new FileCredentialStore();
const current = await credentials.current();
if (!current) return undefined;
return { did: current.did, handle: current.handle };
} catch {
return undefined;
}
}
// ──────────────────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────────────────
/**
* Resolve `slug` and `targetDir` from the positional `name` + `--dir`
* combo. In all modes:
*
* - `init my-plugin` → slug="my-plugin", dir="./my-plugin"
* - `init my-plugin --dir foo` → slug="my-plugin", dir="./foo"
* - `init --dir foo` → slug=basename(foo), dir="./foo"
* - `init` → slug=basename(cwd), dir=cwd
*/
function resolveSlugAndDir(args: InitArgs): { slug: string; targetDir: string } {
const name = nonEmpty(args.name);
const dirArg = nonEmpty(args.dir);
if (name !== undefined) {
const slug = name;
const targetDir = dirArg !== undefined ? resolve(dirArg) : resolve(`./${slug}`);
return { slug, targetDir };
}
const targetDir = dirArg !== undefined ? resolve(dirArg) : resolve(".");
const slug = basename(targetDir);
return { slug, targetDir };
}
function printNextSteps(targetDir: string, inputs: ScaffoldInputs, interactive: boolean): void {
const todos: string[] = [];
if (inputs.publisher === undefined) todos.push("publisher");
if (inputs.author === undefined) todos.push("author");
if (inputs.security === undefined) todos.push("security");
if (interactive) {
const lines: string[] = [];
if (todos.length > 0) {
lines.push(
`${pc.yellow("⚠")} Fill in the TODO placeholders in emdash-plugin.jsonc (${todos.join(", ")}) before bundling.`,
);
}
lines.push(`1. ${pc.cyan(`cd ${targetDir}`)}`);
lines.push(`2. ${pc.cyan("pnpm install")}`);
lines.push(`3. ${pc.cyan("pnpm test")} confirm the scaffold passes its own test`);
lines.push(`4. Edit src/plugin.ts to add routes and hooks.`);
lines.push(`5. ${pc.cyan("emdash-plugin bundle")} when ready to publish`);
clack.note(lines.join("\n"), "Next steps");
clack.outro(`Plugin ready at ${pc.bold(targetDir)}`);
return;
}
consola.info("");
consola.info("Next steps:");
if (todos.length > 0) {
consola.info(
` ${pc.yellow("!")} Fill in the TODO placeholders in ${pc.dim(`${targetDir}/emdash-plugin.jsonc`)} (${todos.join(", ")}) before bundling.`,
);
}
consola.info(` 1. ${pc.cyan(`cd ${targetDir}`)}`);
consola.info(` 2. ${pc.cyan("pnpm install")}`);
consola.info(` 3. ${pc.cyan("pnpm test")} # confirm the scaffold passes its own test`);
consola.info(` 4. Edit ${pc.dim("src/plugin.ts")} to add routes and hooks.`);
consola.info(` 5. ${pc.cyan("emdash-plugin bundle")} # when ready to publish`);
}
/**
* clack prompts return either the answer value or `Symbol.for("clack:cancel")`
* when the user hits Ctrl+C / ESC. We turn that into a clean cancel-and-
* exit rather than letting it propagate as an unrelated runtime error.
*/
function assertNotCancelled(value: unknown): void {
if (clack.isCancel(value)) {
clack.cancel("Cancelled.");
process.exit(0);
}
}
/**
* Normalise clack's prompt return value to a trimmed string. `text()`
* returns `string | symbol`; the symbol case is handled separately by
* `assertNotCancelled`, so by the time this runs the value is either a
* string or something we treat as empty.
*/
function stringOrEmpty(value: unknown): string {
if (typeof value !== "string") return "";
return value.trim();
}
/**
* Trim+empty-string treats `--flag=`, `--flag ""`, and an unprovided
* flag identically. citty leaves explicit empty strings as `""`; we
* normalise to `undefined` so downstream branching is uniform.
*/
function nonEmpty(value: string | undefined): string | undefined {
if (value === undefined) return undefined;
const trimmed = value.trim();
return trimmed.length === 0 ? undefined : trimmed;
}
/**
* Thrown for CLI-input validation failures (invalid slug, malformed
* publisher). Distinct from `InitError` (filesystem / conflict
* failures) so the outer dispatch can produce a different exit class
* if we ever add more granular codes.
*/
class InputError extends Error {
override readonly name = "InputError";
}
+169
View File
@@ -0,0 +1,169 @@
/**
* `emdash-plugin login <handle-or-did>`
*
* Interactive atproto OAuth login. Spins up a loopback HTTP server, opens the
* user's browser at the AS authorization URL, awaits the callback, exchanges
* the code, and persists the resulting session.
*
* Records the publisher's DID/handle/PDS into the EmDash credentials store
* (`~/.emdash/credentials.json` by default) so subsequent registry commands
* can identify the active publisher without cracking open the OAuth library's
* `StoredSession`.
*/
import { isHandle } from "@atcute/lexicons/syntax";
import { OAuthCallbackError, OAuthResponseError } from "@atcute/oauth-node-client";
import { FileCredentialStore } from "@emdash-cms/registry-client";
import { defineCommand } from "citty";
import { consola } from "consola";
import pc from "picocolors";
import { runInteractiveLogin } from "../oauth.js";
import { resolveAtprotoProfile } from "../profile.js";
const BODY_SNIPPET_MAX_CHARS = 200;
const WHITESPACE_RUN = /\s+/g;
/**
* Read up to `BODY_SNIPPET_MAX_CHARS` of the response body, collapsed to a
* single line. Returns null if the body is empty or can't be read. Used to
* surface what the PDS actually returned when the OAuth library couldn't
* extract an `error` / `error_description` from the JSON body and fell back
* to its `unknown_error` placeholder.
*/
async function readBodySnippet(response: Response): Promise<string | null> {
try {
const text = await response.clone().text();
if (!text) return null;
const oneLine = text.replace(WHITESPACE_RUN, " ").trim();
if (!oneLine) return null;
return oneLine.length > BODY_SNIPPET_MAX_CHARS
? `${oneLine.slice(0, BODY_SNIPPET_MAX_CHARS)}`
: oneLine;
} catch {
return null;
}
}
/**
* Render an OAuth response error as a clean CLI message. The atcute library
* surfaces these as `OAuthResponseError` with `error: "unknown_error"` whenever
* the AS response wasn't an OAuth-shaped JSON body — most often a transient
* gateway/PDS hiccup. Without this, users just see "ERROR unknown_error" and a
* stack trace, which doesn't help them tell "PDS hiccup" from "config issue".
*/
async function reportOAuthFailure(error: OAuthResponseError): Promise<void> {
const { status } = error;
const statusText = error.response.statusText;
const endpoint = error.response.url;
const statusLine = statusText ? `HTTP ${status} ${statusText}` : `HTTP ${status}`;
consola.error(`Login failed: PDS responded with ${statusLine}`);
if (endpoint) consola.info(`Endpoint: ${pc.dim(endpoint)}`);
if (error.errorDescription) {
consola.info(`Reason: ${error.errorDescription}`);
} else if (error.error && error.error !== "unknown_error") {
consola.info(`Reason: ${error.error}`);
}
if (error.error === "unknown_error") {
const snippet = await readBodySnippet(error.response);
if (snippet) consola.info(`Body: ${pc.dim(snippet)}`);
}
if (status >= 500) {
consola.info("This looks like a transient server error — try again in a moment.");
}
}
export const loginCommand = defineCommand({
meta: {
name: "login",
description: "Log in to the plugin registry via your Atmosphere account (atproto OAuth)",
},
args: {
identifier: {
type: "positional",
description: "Your handle (e.g. alice.example.com) or DID",
required: true,
},
json: {
type: "boolean",
description: "Output result as JSON",
},
},
async run({ args }) {
const identifier = args.identifier.trim();
consola.start(`Logging in as ${pc.bold(identifier)}...`);
let result: Awaited<ReturnType<typeof runInteractiveLogin>>;
try {
result = await runInteractiveLogin({
identifier,
onUrl: (url) => {
console.log();
consola.info("Open your browser to:");
console.log(` ${pc.cyan(pc.bold(url.toString()))}`);
console.log();
consola.info("Waiting for authorization...");
},
onLegacyScopeFallback: () => {
consola.warn(
"Your PDS rejected the granular OAuth scopes; falling back to legacy `transition:generic`.",
);
consola.info(
"This grants the CLI broader permissions than it needs. Ask your PDS operator to update to a build that supports the atproto granular permission spec.",
);
},
});
} catch (error) {
if (error instanceof OAuthResponseError) {
await reportOAuthFailure(error);
process.exit(1);
}
if (error instanceof OAuthCallbackError) {
consola.error(`Login failed: ${error.errorDescription ?? error.error}`);
if (error.errorDescription && error.error !== error.errorDescription) {
consola.info(`Error code: ${error.error}`);
}
process.exit(1);
}
throw error;
}
const { displayName, handle, pds } = await resolveAtprotoProfile(result.session);
// `resolveAtprotoProfile` falls back to the DID when handle
// resolution fails. We persist `null` rather than a placeholder so
// downstream display code can render the DID directly instead of a
// fake "unknown.invalid"-style handle that misleads users.
const handleForStorage: string | null = isHandle(handle) ? handle : null;
const credentials = new FileCredentialStore();
await credentials.put({
did: result.did,
handle: handleForStorage,
pds,
updatedAt: Date.now(),
});
if (args.json) {
console.log(
JSON.stringify({
did: result.did,
handle: handleForStorage,
displayName,
pds,
}),
);
return;
}
consola.success(
`Logged in as ${pc.bold(handleForStorage ?? result.did)}${displayName ? ` (${displayName})` : ""}`,
);
if (handleForStorage) consola.info(`DID: ${pc.dim(result.did)}`);
if (pds) consola.info(`PDS: ${pc.dim(pds)}`);
},
});
@@ -0,0 +1,52 @@
/**
* `emdash-plugin logout [--did <did>]`
*
* Revoke the active publisher session and remove its stored state.
*
* Without `--did`, removes the current session. With `--did`, removes the
* session for that specific DID (useful if the user has multiple stored).
*/
import { isDid } from "@atcute/lexicons/syntax";
import { FileCredentialStore, type Did } from "@emdash-cms/registry-client";
import { defineCommand } from "citty";
import { consola } from "consola";
import { revokeSession } from "../oauth.js";
export const logoutCommand = defineCommand({
meta: {
name: "logout",
description: "Log out of the plugin registry, revoking the active publisher session",
},
args: {
did: {
type: "string",
description: "Specific DID to log out. Defaults to the current session.",
},
},
async run({ args }) {
const credentials = new FileCredentialStore();
let did: Did;
if (args.did) {
if (!isDid(args.did)) {
consola.error(`"${args.did}" is not a valid DID`);
process.exit(2);
}
did = args.did;
} else {
const current = await credentials.current();
if (!current) {
consola.info("No active session to log out from.");
return;
}
did = current.did;
}
await revokeSession(did);
await credentials.remove(did);
consola.success(`Logged out: ${did}`);
},
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,95 @@
/**
* `emdash-plugin search <query> [--capability <name>] [--limit <n>] [--cursor <c>]`
*
* Free-text search the aggregator. Read-only; no auth required.
*/
import { DiscoveryClient } from "@emdash-cms/registry-client";
import { defineCommand } from "citty";
import { consola } from "consola";
import pc from "picocolors";
import { resolveAggregatorUrl } from "../config.js";
export const searchCommand = defineCommand({
meta: {
name: "search",
description: "Search the plugin registry by free-text query",
},
args: {
query: {
type: "positional",
description: "Search query (matches name, description, keywords, authors)",
required: true,
},
capability: {
type: "string",
description: "Filter to packages declaring this access category (e.g. 'email', 'network')",
},
limit: {
type: "string",
description: "Max results per page (1-100, default 25)",
default: "25",
},
cursor: {
type: "string",
description:
"Continuation cursor from a previous search result (printed at the end when more results exist)",
},
"registry-url": {
type: "string",
description: "Override registry URL (defaults to EMDASH_REGISTRY_URL or experimental host)",
},
json: {
type: "boolean",
description: "Output as JSON",
},
},
async run({ args }) {
const aggregatorUrl = resolveAggregatorUrl(args["registry-url"]);
const limit = clamp(parseInt(args.limit, 10) || 25, 1, 100);
const client = new DiscoveryClient({ aggregatorUrl });
const result = await client.searchPackages({
q: args.query,
...(args.capability ? { capability: args.capability } : {}),
...(args.cursor ? { cursor: args.cursor } : {}),
limit,
});
if (args.json) {
console.log(JSON.stringify(result, null, 2));
return;
}
if (result.packages.length === 0) {
consola.info(`No packages match "${args.query}".`);
return;
}
console.log();
for (const pkg of result.packages) {
// `pkg.profile` is lexicon-validated by DiscoveryClient (or null).
const profile = pkg.profile;
console.log(`${pc.bold(profile?.name ?? pkg.slug)} ${pc.dim(`(${pkg.slug})`)}`);
if (profile?.description) console.log(` ${profile.description}`);
console.log(` ${pc.dim(pkg.uri)}`);
console.log();
}
if (result.cursor) {
// Cursor-based pagination: callers paginate by passing the cursor
// back in, not by bumping --limit. The aggregator caps `limit` at
// 100, so suggesting "increase the limit" was misleading advice.
consola.info(
`More results available. Continue with: ${pc.cyan(
`emdash-plugin search "${args.query}" --cursor ${result.cursor}`,
)}`,
);
}
},
});
function clamp(n: number, min: number, max: number): number {
return Math.max(min, Math.min(max, n));
}
@@ -0,0 +1,51 @@
/**
* `emdash-plugin switch <did>`
*
* Change the active publisher session. The DID must already be in the
* credentials store (i.e. you've previously logged in as it). Use
* `emdash-plugin whoami` to see stored sessions.
*
* The OAuth library still resolves a refreshed access token by DID on the
* next publish; this command only changes which DID is "current" for the
* convenience of subsequent commands.
*/
import { isDid } from "@atcute/lexicons/syntax";
import { FileCredentialStore } from "@emdash-cms/registry-client";
import { defineCommand } from "citty";
import { consola } from "consola";
import pc from "picocolors";
export const switchCommand = defineCommand({
meta: {
name: "switch",
description: "Switch the active publisher session to another stored DID",
},
args: {
did: {
type: "positional",
description: "DID to switch to. Must already be in the credentials store.",
required: true,
},
},
async run({ args }) {
if (!isDid(args.did)) {
consola.error(`"${args.did}" is not a valid DID`);
process.exit(2);
}
const credentials = new FileCredentialStore();
const target = await credentials.get(args.did);
if (!target) {
consola.error(
`No stored session for ${args.did}. Run: emdash-plugin whoami to list stored sessions.`,
);
process.exit(1);
}
await credentials.setCurrent(args.did);
consola.success(
`Active publisher is now ${pc.bold(target.handle ?? target.did)} (${pc.dim(target.did)})`,
);
},
});
@@ -0,0 +1,373 @@
/**
* `emdash-plugin update-package [--manifest <path>] [--yes] [--json]`
*
* Edit an already-published package record without cutting a new release.
* Operates on the `com.emdashcms.experimental.package.profile` record — the
* registry's per-package metadata, not the publisher's atproto profile.
*
* Reads `emdash-plugin.jsonc`, fetches the existing package record from the
* publisher's PDS, diffs the manifest's lexicon-controlled fields against
* what's on the PDS, and (with `--yes`) writes the updated record back via
* `com.atproto.repo.putRecord`. Without `--yes`, prints the diff and exits 0.
*
* The slug is the record key; the command refuses to change it (renames
* would orphan every release tied to the old slug). `lastUpdated` is
* auto-bumped to now on any successful write and is never user-editable.
*
* Fields the manifest controls and this command can update:
* - `license`
* - `authors` / `author`
* - `security` / `securityContacts`
* - `name`
* - `description`
* - `keywords`
* - `sections`
*
* Fields preserved verbatim from the existing record: `$type`, `id`, `slug`,
* `type`, plus any unknown forward-compatible fields from a future lexicon
* revision.
*/
import { readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { FileCredentialStore, PublishingClient } from "@emdash-cms/registry-client";
import { defineCommand } from "citty";
import consola from "consola";
import pc from "picocolors";
import { redirectConsolaToStderr } from "../cli-output.js";
import { loadManifest, MANIFEST_FILENAME, ManifestError } from "../manifest/load.js";
import { checkPublisher, PublisherCheckError } from "../manifest/publisher.js";
import {
manifestToProfileInput,
normaliseManifest,
resolveSections,
SectionError,
} from "../manifest/translate.js";
import { resumeSession } from "../oauth.js";
import {
updatePackage,
UpdatePackageError,
type PackageFieldDiff,
type PackageUpdateInput,
type UpdatePackageResult,
} from "../update-package/api.js";
export const updatePackageCommand = defineCommand({
meta: {
name: "update-package",
description:
"Update an already-published plugin's registry record without cutting a new release (license, authors, security contacts, name/description/keywords).",
},
args: {
manifest: {
type: "string",
description: `Path to emdash-plugin.jsonc, or the directory containing it. Defaults to ./${MANIFEST_FILENAME}.`,
},
yes: {
type: "boolean",
description:
"Apply the diff. Without this flag, the command runs as a dry-run: prints what would change and exits 0 without writing.",
default: false,
},
json: {
type: "boolean",
description:
"Emit a single-line JSON object on stdout instead of human output. Success: {profile, written, applied, diffs, cid?}. Failure: {error: {code, message, detail?}}. Human-readable progress goes to stderr.",
},
},
async run({ args }) {
const restoreReporters = args.json ? redirectConsolaToStderr() : null;
let exitCode = 0;
try {
await runUpdatePackage(args);
} catch (error) {
exitCode = error instanceof CliError ? error.exitCode : 1;
handleUpdatePackageError(error, args.json);
} finally {
restoreReporters?.();
}
if (exitCode !== 0) process.exit(exitCode);
},
});
interface UpdatePackageArgs {
manifest?: string;
yes?: boolean;
json?: boolean;
}
async function runUpdatePackage(args: UpdatePackageArgs): Promise<void> {
const manifestPath = args.manifest ?? `./${MANIFEST_FILENAME}`;
const manifestLoad = await loadManifestForUpdate(manifestPath);
const credentials = new FileCredentialStore();
const session = await credentials.current();
if (!session) {
throw new CliError(
"Not logged in. Run: emdash-plugin login <handle-or-did>",
1,
"NOT_LOGGED_IN",
);
}
consola.info(`Editing as ${pc.bold(session.handle ?? session.did)} (${pc.dim(session.did)})`);
try {
const check = await checkPublisher({
manifestPublisher: manifestLoad.manifest.publisher,
sessionDid: session.did,
});
if (check.kind === "mismatch") {
throw new CliError(
`Manifest pins publisher to ${pc.bold(check.pinnedDisplay)} (${check.pinnedDid}), but the active session is ${session.did}. ` +
`Either switch sessions (\`emdash-plugin switch ${check.pinnedDid}\`), or edit the manifest if you are transferring the plugin to a new publisher.`,
1,
"MANIFEST_PUBLISHER_MISMATCH",
);
}
} catch (error) {
if (error instanceof PublisherCheckError) {
throw new CliError(error.message, 1, error.code);
}
throw error;
}
const oauthSession = await resumeSession(session.did);
const publisher = PublishingClient.fromHandler({
handler: oauthSession,
did: session.did,
pds: session.pds,
});
const input: PackageUpdateInput = packageUpdateInputFromManifest(manifestLoad.manifest);
const result = await updatePackage({
publisher,
slug: manifestLoad.manifest.slug,
input,
apply: args.yes ?? false,
});
if (args.json) {
process.stdout.write(`${JSON.stringify(formatJsonResult(result, args.yes ?? false))}\n`);
return;
}
renderResult(result, args.yes ?? false);
}
/**
* Result of resolving the manifest for `runUpdatePackage`. Surfaces the
* normalised manifest with `manifest.version` resolved against the sibling
* `package.json` (mirrors the publish path so the two commands agree on
* which manifest they're talking about — even though version isn't part of
* the profile record).
*/
interface ManifestLoadOutcome {
path: string;
manifest: ReturnType<typeof normaliseManifest>;
}
async function loadManifestForUpdate(path: string): Promise<ManifestLoadOutcome> {
try {
const { manifest, path: resolvedPath } = await loadManifest(path);
const packageVersion = await readSiblingPackageVersion(dirname(resolvedPath));
let normalised: ReturnType<typeof normaliseManifest>;
try {
normalised = normaliseManifest(manifest, packageVersion);
} catch (error) {
if (error instanceof Error && "code" in error) {
const code = (error as { code: unknown }).code;
if (code === "VERSION_MISSING" || code === "VERSION_MISMATCH") {
throw new CliError(error.message, 1, String(code));
}
}
throw error;
}
try {
normalised.sections = await resolveSections(manifest.sections, dirname(resolvedPath));
} catch (error) {
if (error instanceof SectionError) {
throw new CliError(error.message, 1, error.code);
}
throw error;
}
consola.info(`Loaded manifest: ${pc.dim(resolvedPath)}`);
return { path: resolvedPath, manifest: normalised };
} catch (error) {
if (error instanceof ManifestError) {
throw new CliError(error.message, 1, error.code);
}
throw error;
}
}
/**
* Read `package.json#version` from the directory containing the manifest.
* Mirrors the publish path. Missing or unparseable package.json is non-
* fatal for update-package (version isn't part of the profile record),
* but malformed JSON still surfaces so a typo doesn't pass silently.
*/
async function readSiblingPackageVersion(manifestDir: string): Promise<string | undefined> {
const packageJsonPath = join(manifestDir, "package.json");
let source: string;
try {
source = await readFile(packageJsonPath, "utf-8");
} catch (error) {
if (
error instanceof Error &&
"code" in error &&
(error as { code: unknown }).code === "ENOENT"
) {
return undefined;
}
throw new CliError(
`Failed to read package.json at ${packageJsonPath}: ${error instanceof Error ? error.message : String(error)}`,
1,
"PACKAGE_JSON_UNREADABLE",
);
}
let parsed: unknown;
try {
parsed = JSON.parse(source);
} catch (error) {
throw new CliError(
`package.json at ${packageJsonPath} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
1,
"PACKAGE_JSON_INVALID",
);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new CliError(
`package.json at ${packageJsonPath} must be a JSON object.`,
1,
"PACKAGE_JSON_INVALID",
);
}
const version = (parsed as { version?: unknown }).version;
return typeof version === "string" ? version : undefined;
}
/**
* Project the manifest's package-record fields into the
* `PackageUpdateInput` contract. We reuse `manifestToProfileInput` from
* the publish path so the two commands agree on what comes out of the
* manifest, then drop the first-publish-only fields that update-package
* doesn't touch.
*/
function packageUpdateInputFromManifest(
manifest: ReturnType<typeof normaliseManifest>,
): PackageUpdateInput {
const profile = manifestToProfileInput(manifest);
const input: PackageUpdateInput = {
// license is required in the manifest schema, so it's always
// present in `manifestToProfileInput`'s output. The non-null
// assertion would be wrong if the manifest schema ever relaxes
// that; assigning via fallback keeps the cast honest.
license: profile.license ?? "",
authors: profile.authors ?? [],
security: profile.security ?? [],
};
if (profile.name !== undefined) input.name = profile.name;
if (profile.description !== undefined) input.description = profile.description;
if (profile.keywords !== undefined) input.keywords = profile.keywords;
if (profile.sections !== undefined) input.sections = profile.sections;
return input;
}
function renderResult(result: UpdatePackageResult, applied: boolean): void {
if (result.diffs.length === 0) {
consola.success(`Package at ${pc.dim(result.profileUri)} is already up to date.`);
return;
}
console.log();
console.log(pc.bold(applied ? "Applied package changes:" : "Package changes (dry-run):"));
for (const diff of result.diffs) {
renderDiffLine(diff);
}
console.log();
if (applied && result.written) {
consola.success(`Updated package: ${pc.dim(result.profileUri)}`);
if (result.cid) {
consola.info(`New CID: ${pc.dim(result.cid)}`);
}
} else {
consola.info(
`Dry-run complete. Re-run with ${pc.bold("--yes")} to write these changes to your PDS.`,
);
}
}
function renderDiffLine(diff: PackageFieldDiff): void {
const beforeStr = formatFieldValue(diff.before);
const afterStr = formatFieldValue(diff.after);
const removed = diff.after === undefined;
const added = diff.before === undefined;
const marker = added ? pc.green("+") : removed ? pc.red("-") : pc.yellow("~");
console.log(` ${marker} ${pc.bold(diff.field)}`);
if (!added) console.log(` ${pc.red(`- ${beforeStr}`)}`);
if (!removed) console.log(` ${pc.green(`+ ${afterStr}`)}`);
}
function formatFieldValue(value: unknown): string {
if (value === undefined) return "(unset)";
const str = typeof value === "string" ? value : JSON.stringify(value);
// Sections (and other long fields) can be tens of KB; keep the diff readable.
return str.length > 160 ? `${str.slice(0, 159)}` : str;
}
function formatJsonResult(result: UpdatePackageResult, applied: boolean): Record<string, unknown> {
const body: Record<string, unknown> = {
profile: result.profileUri,
written: result.written,
applied,
diffs: result.diffs.map((d) => ({
field: d.field,
before: d.before ?? null,
after: d.after ?? null,
})),
};
if (result.cid) body.cid = result.cid;
return body;
}
function handleUpdatePackageError(error: unknown, jsonMode: boolean | undefined): void {
let code = "INTERNAL_ERROR";
let message = "Internal error";
let detail: Record<string, unknown> | undefined;
if (error instanceof UpdatePackageError) {
code = error.code;
message = error.message;
detail = error.detail;
consola.error(error.message);
} else if (error instanceof CliError) {
code = error.code;
message = error.message;
consola.error(error.message);
} else if (error instanceof Error) {
message = error.message;
consola.error(error);
} else {
message = String(error);
consola.error(error);
}
if (jsonMode) {
const body: Record<string, unknown> = { code, message };
if (detail !== undefined) body.detail = detail;
process.stdout.write(`${JSON.stringify({ error: body })}\n`);
}
}
class CliError extends Error {
override readonly name = "CliError";
constructor(
message: string,
readonly exitCode: number,
readonly code: string,
) {
super(message);
}
}
@@ -0,0 +1,95 @@
/**
* `emdash-plugin validate [path]`
*
* Validate an `emdash-plugin.jsonc` manifest against the v1 schema.
*
* Exit codes:
*
* - 0: manifest is schema-valid.
* - 1: validation failed; details on stderr (human mode) or stdout (JSON mode).
* - 2: usage error (e.g. invalid `--json` combination).
*
* The CLI does not check publish-time invariants here (e.g. license required
* on first publish vs ignored on subsequent). Those checks live in
* `publishRelease` and require network access. `validate` is a fast, offline
* sanity check that's safe to wire into `pre-commit` / CI.
*/
import { dirname } from "node:path";
import { defineCommand } from "citty";
import consola from "consola";
import pc from "picocolors";
import { ManifestError, loadManifest, MANIFEST_FILENAME } from "../manifest/load.js";
import { resolveSections, SectionError } from "../manifest/translate.js";
export const validateCommand = defineCommand({
meta: {
name: "validate",
description:
"Validate an emdash-plugin.jsonc manifest against the v1 schema (offline; no network access).",
},
args: {
path: {
type: "positional",
required: false,
description: `Path to the manifest, or the directory containing it. Defaults to ./${MANIFEST_FILENAME}.`,
},
json: {
type: "boolean",
description:
"Emit machine-readable JSON instead of human-readable output. Stdout is { ok: true, path } or { ok: false, error: { code, message, issues } }. Exit code mirrors human mode.",
},
},
async run({ args }) {
const path = args.path ?? ".";
try {
const { manifest, path: resolved } = await loadManifest(path);
// Resolve `{ file }` section refs offline: reads their content and
// enforces the per-section byte/grapheme caps and the path-escape
// guard, so a bad ref fails here instead of at publish time.
await resolveSections(manifest.sections, dirname(resolved));
if (args.json) {
process.stdout.write(`${JSON.stringify({ ok: true, path: resolved })}\n`);
return;
}
consola.success(`Manifest is valid: ${pc.dim(resolved)}`);
} catch (error) {
if (error instanceof ManifestError) {
if (args.json) {
process.stdout.write(
`${JSON.stringify({
ok: false,
error: {
code: error.code,
message: error.message,
path: error.path,
issues: error.issues,
},
})}\n`,
);
} else {
consola.error(error.message);
}
process.exit(1);
}
if (error instanceof SectionError) {
if (args.json) {
process.stdout.write(
`${JSON.stringify({
ok: false,
error: { code: error.code, message: error.message, section: error.section },
})}\n`,
);
} else {
consola.error(error.message);
}
process.exit(1);
}
throw error;
}
},
});
@@ -0,0 +1,59 @@
/**
* `emdash-plugin whoami`
*
* Show the active publisher session, plus a short list of any other stored
* sessions. Read-only: this command never refreshes tokens or hits the network.
*/
import { FileCredentialStore } from "@emdash-cms/registry-client";
import { defineCommand } from "citty";
import { consola } from "consola";
import pc from "picocolors";
export const whoamiCommand = defineCommand({
meta: {
name: "whoami",
description: "Show the active publisher and any other stored sessions",
},
args: {
json: {
type: "boolean",
description: "Output as JSON",
},
},
async run({ args }) {
const credentials = new FileCredentialStore();
const current = await credentials.current();
const all = await credentials.list();
if (args.json) {
console.log(
JSON.stringify({
current: current ?? null,
sessions: all,
}),
);
return;
}
if (!current) {
consola.info("Not logged in. Run: emdash-plugin login <handle-or-did>");
return;
}
consola.info(`Active publisher: ${pc.bold(current.handle ?? current.did)}`);
consola.info(`DID: ${pc.dim(current.did)}`);
if (current.pds) consola.info(`PDS: ${pc.dim(current.pds)}`);
const others = all.filter((s) => s.did !== current.did);
if (others.length > 0) {
console.log();
consola.info(`Other stored sessions (${others.length}):`);
for (const s of others) {
console.log(` ${pc.dim(s.handle ?? s.did)} (${pc.dim(s.did)})`);
}
console.log();
consola.info(`Switch with: ${pc.cyan("emdash-plugin switch <did>")}`);
}
},
});
+41
View File
@@ -0,0 +1,41 @@
/**
* Shared config for the registry CLI.
*
* The aggregator URL defaults to the experimental host but can be overridden
* per-invocation via `--registry-url <url>` or by the `EMDASH_REGISTRY_URL`
* env var. We resolve in that order, falling back to the default.
*
* EXPERIMENTAL: the default host is provisional. It will be retired and
* replaced at phase 1 cutover; pin the override flag if you depend on a
* specific aggregator.
*/
import { homedir } from "node:os";
import { join } from "node:path";
/**
* Default aggregator URL during the experimental phase. The exact host is TBD;
* this constant is the single place to update once we deploy the first
* aggregator. See `.opencode/plans/plugin-registry-implementation.md`
* § "Open questions".
*/
export const DEFAULT_AGGREGATOR_URL = "https://registry.emdashcms.com";
/**
* Default directory for OAuth state (sessions, in-flight authorize states).
* Co-located with the credentials store from `@emdash-cms/registry-client` so
* users have one place to clean up if they want to wipe registry credentials.
*/
export const DEFAULT_OAUTH_DIR = join(homedir(), ".emdash", "oauth");
/**
* Resolves the aggregator URL for the current invocation.
*
* Precedence: explicit flag > env var > default.
*/
export function resolveAggregatorUrl(flag?: string): string {
if (flag && flag.length > 0) return flag;
const fromEnv = process.env["EMDASH_REGISTRY_URL"];
if (fromEnv && fromEnv.length > 0) return fromEnv;
return DEFAULT_AGGREGATOR_URL;
}
+236
View File
@@ -0,0 +1,236 @@
/**
* `emdash-plugin dev`
*
* Watch mode wrapper around `buildPlugin`. Rebuilds the plugin
* whenever `src/**`, `emdash-plugin.jsonc`, or `package.json` change.
*
* Behaviour:
*
* - Logs a divider + timestamp + result per rebuild. Doesn't clear
* the screen — authors keep a scrollback of what happened.
* - On error, prints the BuildError's structured code + message.
* Does *not* wipe `dist/` — the last successful build stays on
* disk so a downstream site importing the plugin keeps working
* until the next successful rebuild.
* - Debounces rapid bursts (editors saving multiple files) at
* 150ms so a single edit doesn't trigger several rebuilds.
* - Serialises builds: if a change arrives while one is in flight,
* a follow-up build is queued and runs after the current one
* completes. This prevents a slow earlier build from overwriting
* dist/ with stale output after a newer build has already
* finished.
* - SIGINT (Ctrl-C) waits for any in-flight build before closing
* the watcher and exits 0. A second signal during shutdown
* forces immediate exit so an impatient Ctrl-Ctrl-C still works.
*
* Known limitation: the build pipeline's probe step dynamically
* imports the freshly-built plugin module to harvest hook/route names.
* Each probe goes to a unique temp file, and Node's ESM loader caches
* modules by URL with no eviction. Across many rebuilds the loader's
* cache grows monotonically (each leaked module is small — kilobytes —
* but the count is unbounded). Restart `dev` after long sessions; a
* future refactor will harvest the surface via AST instead of import().
*/
import { isAbsolute, relative, resolve, sep } from "node:path";
import { defineCommand } from "citty";
import consola from "consola";
import pc from "picocolors";
import { BuildError, buildPlugin, type BuildLogger } from "../build/api.js";
const DEBOUNCE_MS = 150;
/**
* Files / globs the watcher tracks for change events. Relative to the
* plugin directory; chokidar's `cwd` option resolves them.
*/
const WATCH_GLOBS = ["src/**", "emdash-plugin.jsonc", "package.json"];
export const devCommand = defineCommand({
meta: {
name: "dev",
description: "Watch a sandboxed plugin's sources and rebuild on change",
},
args: {
dir: {
type: "string",
description: "Plugin directory (default: current directory)",
default: process.cwd(),
},
"out-dir": {
type: "string",
alias: "o",
description: "Output directory (default: ./dist)",
default: "dist",
},
},
async run({ args }) {
const { default: chokidar } = await import("chokidar");
// Lifted out so scheduleRebuild can short-circuit any
// post-Ctrl-C file events while the watcher is still draining.
let shutdownStarted = false;
const logger: BuildLogger = {
start: (m) => consola.start(m),
info: (m) => consola.info(m),
success: (m) => consola.success(m),
warn: (m) => consola.warn(m),
};
// Serialisation state. `pending` holds the in-flight build's
// promise; `queued` is a single follow-up trigger label
// (collapsing multiple-rebuilds-during-build into one). When
// the current build settles, the queued trigger fires.
let pending: Promise<void> | undefined;
let queuedTrigger: string | undefined;
const runBuild = async (label: string): Promise<void> => {
const stamp = new Date().toLocaleTimeString();
console.log();
console.log(pc.dim(`── ${label} at ${stamp} ─────────────────────`));
try {
await buildPlugin({
dir: args.dir,
outDir: args["out-dir"],
logger,
});
} catch (error) {
if (error instanceof BuildError) {
consola.error(`${pc.bold(error.code)}: ${error.message}`);
} else {
consola.error(error instanceof Error ? error.message : String(error));
}
consola.info(
pc.dim("Last successful build (if any) is still in dist/. Waiting for changes..."),
);
}
};
/**
* Schedule a build. If one's running, queue the trigger so we
* rebuild after it finishes; the queued trigger is collapsed
* (only the most recent change label survives). Otherwise
* starts immediately and tracks the promise in `pending` so
* subsequent triggers know to queue.
*/
const startBuild = (label: string): void => {
if (pending) {
queuedTrigger = label;
return;
}
pending = (async () => {
// try/finally so state always clears even if a future
// runBuild throws past its own catch (e.g. logger
// write error during shutdown). A stuck `pending`
// would deadlock the watcher.
try {
let currentLabel = label;
while (currentLabel) {
await runBuild(currentLabel);
currentLabel = queuedTrigger ?? "";
queuedTrigger = undefined;
}
} finally {
pending = undefined;
queuedTrigger = undefined;
}
})();
};
// Initial build before starting the watcher. If it fails the
// watcher still starts so the author can fix the error and
// re-trigger. Run synchronously so the user sees the result
// before we print "Watching".
await runBuild("initial build");
// Resolve outDir relative to the plugin dir so the ignore
// pattern matches whatever the user passed for `--out-dir`.
// chokidar wants forward-slash globs even on Windows, so
// normalise the platform separator (path.sep) to "/".
const resolvedOutDir = resolve(args.dir, args["out-dir"]);
const cwdAbs = resolve(args.dir);
const outDirRel = relative(cwdAbs, resolvedOutDir);
// `outDirGlob` is the ignore pattern only when outDir is
// strictly inside the watched dir. `relative` returns "" when
// the two paths are equal (outDir === plugin root, which would
// be pathological — would write plugin.mjs / manifest.json
// next to src/ and the watcher would loop). Empty string and
// upward / absolute paths fall through to `undefined`.
const outDirGlob =
outDirRel && !outDirRel.startsWith("..") && !isAbsolute(outDirRel)
? `${outDirRel.split(sep).join("/")}/**`
: undefined;
const ignored = ["**/node_modules/**", ...(outDirGlob ? [outDirGlob] : [])];
const watcher = chokidar.watch(WATCH_GLOBS, {
cwd: args.dir,
ignoreInitial: true,
ignored,
});
let timer: NodeJS.Timeout | undefined;
let pendingTrigger: string | undefined;
const scheduleRebuild = (path: string) => {
if (shutdownStarted) return;
pendingTrigger = path;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
const trigger = pendingTrigger ?? "change";
pendingTrigger = undefined;
timer = undefined;
startBuild(`rebuild (${trigger})`);
}, DEBOUNCE_MS);
};
watcher.on("add", scheduleRebuild);
watcher.on("change", scheduleRebuild);
watcher.on("unlink", scheduleRebuild);
watcher.on("error", (error) => {
consola.error(`Watcher error: ${error instanceof Error ? error.message : String(error)}`);
});
consola.info(`Watching ${pc.cyan(args.dir)} for changes (Ctrl-C to stop)`);
// Shutdown waits for the in-flight build (if any) so the user
// doesn't end up with a torn dist/. A second SIGINT during
// shutdown forces immediate exit — impatience is a valid
// signal.
await new Promise<void>((resolveOuter) => {
const shutdown = () => {
if (shutdownStarted) {
consola.warn("Second interrupt — forcing exit.");
process.exit(130);
}
shutdownStarted = true;
consola.info("Stopping watcher (waiting for in-flight build)...");
if (timer) clearTimeout(timer);
queuedTrigger = undefined;
const drainAndClose = async () => {
// Close the watcher before draining `pending` so a
// file change arriving during the wait can't queue
// a new build the user already cancelled.
await watcher.close();
if (pending) {
try {
await pending;
} catch {
/* runBuild swallows its own errors */
}
}
process.off("SIGINT", shutdown);
process.off("SIGTERM", shutdown);
resolveOuter();
};
void drainAndClose();
};
// Use `on` (not `once`) so a second signal during shutdown
// hits the same handler and the impatience branch fires.
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
});
},
});
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env node
/**
* @emdash-cms/plugin-cli
*
* CLI for the experimental EmDash plugin registry. Entry point: `emdash-plugin`.
*
* Subcommands:
* - login — interactive atproto OAuth login
* - logout — revoke the active session
* - whoami — show stored sessions
* - switch — change the active publisher session
* - search — free-text search the aggregator
* - info — show details about a package
* - init — scaffold a new sandboxed plugin
* - build — produce the npm distribution artifacts (dist/index.mjs, dist/plugin.mjs, dist/manifest.json)
* - dev — watch sources and rebuild on change
* - bundle — bundle a plugin source directory into a tarball
* - publish — publish a release that points at a hosted tarball
* - update-package — edit an already-published package without a new release
* - validate — validate an emdash-plugin.jsonc manifest against the v1 schema
*
* EXPERIMENTAL: this CLI targets `com.emdashcms.experimental.*` and the
* experimental aggregator. Pin to an exact version while RFC 0001 is in flight.
*/
import { defineCommand, runMain } from "citty";
import { buildCommand } from "./build/command.js";
import { bundleCommand } from "./bundle/command.js";
import { infoCommand } from "./commands/info.js";
import { initCommand } from "./commands/init.js";
import { loginCommand } from "./commands/login.js";
import { logoutCommand } from "./commands/logout.js";
import { publishCommand } from "./commands/publish.js";
import { searchCommand } from "./commands/search.js";
import { switchCommand } from "./commands/switch.js";
import { updatePackageCommand } from "./commands/update-package.js";
import { validateCommand } from "./commands/validate.js";
import { whoamiCommand } from "./commands/whoami.js";
import { devCommand } from "./dev/command.js";
const main = defineCommand({
meta: {
name: "emdash-plugin",
description: "CLI for the experimental EmDash plugin registry",
},
subCommands: {
login: loginCommand,
logout: logoutCommand,
whoami: whoamiCommand,
switch: switchCommand,
search: searchCommand,
info: infoCommand,
init: initCommand,
build: buildCommand,
dev: devCommand,
bundle: bundleCommand,
publish: publishCommand,
"update-package": updatePackageCommand,
validate: validateCommand,
},
});
// citty's `runMain` only force-exits on error or `--help`; on normal
// completion it just resolves and lets the event loop drain. After a
// successful `login` the success message prints, `run()` returns, but
// something inside atcute / the loopback HTTP path leaves a ref'd handle
// alive indefinitely -- the CLI hangs forever instead of returning to the
// shell. (The same is true for `logout`'s revocation flow.) Force-exit on
// success so users get an immediate prompt back; non-success paths already
// `process.exit` themselves.
void runMain(main).then(() => process.exit(0));
+261
View File
@@ -0,0 +1,261 @@
/**
* Environment-probe helpers for `emdash-plugin init`.
*
* The goal: when the user runs init, pre-fill prompts with whatever the
* surrounding environment already knows. None of these probes are
* authoritative — they're just sensible defaults the user can override.
*
* Sources, in priority order per field:
*
* - git config (user.name, user.email): the canonical "who am I" on
* any developer machine.
* - `git remote get-url origin`: the most reliable source for the
* plugin's repo URL.
* - package.json#description / #license / #repository: catches the
* "scaffolding into an existing repo skeleton" case.
*
* Every probe swallows errors. The CLI uses these as soft defaults; a
* missing git binary, a non-git target dir, an unreadable package.json
* are all expected and silently fall through to "no default".
*/
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
/**
* Hard cap on the bytes we'll read from package.json. Pre-fills are a
* convenience; we don't want to OOM on a deranged 1GB package.json that
* happens to live in the target directory.
*/
const PACKAGE_JSON_MAX_BYTES = 64 * 1024;
/**
* Timeout for the git child-process calls. Local git config / remote
* lookup should complete in milliseconds; anything that hangs is
* almost certainly a misconfigured remote or a network mount. We'd
* rather skip the pre-fill than make `init` feel slow.
*/
const GIT_TIMEOUT_MS = 2_000;
/**
* Snapshot of pre-fill values discovered from the surrounding
* environment. Every field is optional — undefined means "nothing
* found", and the caller should fall back to its own default.
*/
export interface EnvironmentDefaults {
authorName: string | undefined;
authorEmail: string | undefined;
license: string | undefined;
description: string | undefined;
repo: string | undefined;
}
/**
* Probe the environment for pre-fillable values. Inspects:
*
* 1. git config (global and per-dir) for user.name + user.email.
* 2. `git remote get-url origin` (with normalization) for the repo URL.
* 3. `<targetDir>/package.json` for description / license / repository.
*
* Errors in any one probe don't abort the others. The function returns
* whatever it could determine, with each missing field as undefined.
*/
export async function probeEnvironment(targetDir: string): Promise<EnvironmentDefaults> {
// Run independent probes in parallel — they don't depend on each
// other and each one is the slow path of a single fs/exec call.
const [authorName, authorEmail, repoFromGit, fromPackageJson] = await Promise.all([
gitConfig("user.name", targetDir),
gitConfig("user.email", targetDir),
gitRemoteUrl(targetDir),
readPackageJson(targetDir),
]);
return {
authorName,
authorEmail,
// package.json#repository overrides the git remote only if it
// looks like a deliberate, complete URL (the git remote is the
// stronger signal of "where this code actually lives", but if a
// pre-existing package.json points elsewhere, respect it).
repo: fromPackageJson.repo ?? repoFromGit,
license: fromPackageJson.license,
description: fromPackageJson.description,
};
}
// ──────────────────────────────────────────────────────────────────────────
// Git config
// ──────────────────────────────────────────────────────────────────────────
/**
* Read a git config value, falling back from the target dir to the
* global config. Returns undefined if git isn't installed, the dir
* isn't a repo, or the key is unset.
*
* We use `git config --get` rather than `git config --show-origin` etc.
* because `--get` is the simplest "give me the effective value" form
* and it walks the repo→global→system fallback chain itself.
*/
async function gitConfig(key: string, cwd: string): Promise<string | undefined> {
try {
const { stdout } = await execFileAsync("git", ["config", "--get", key], {
cwd,
timeout: GIT_TIMEOUT_MS,
// Limit output size to defend against a deliberately-bizarre
// git config value. 4 KiB is generous for "name" / "email".
maxBuffer: 4096,
});
const value = stdout.trim();
return value.length === 0 ? undefined : value;
} catch {
return undefined;
}
}
// ──────────────────────────────────────────────────────────────────────────
// Git remote
// ──────────────────────────────────────────────────────────────────────────
const GIT_SSH_RE = /^git@([^:]+):(.+?)(?:\.git)?$/;
const HTTPS_TRAILING_GIT_RE = /\.git$/;
const GIT_URL_PREFIX_RE = /^git\+/;
/**
* Read `origin` remote URL and normalize to https. `git@github.com:foo/bar.git`
* becomes `https://github.com/foo/bar`. Returns undefined if there's no
* origin remote, no git, or the URL doesn't normalize to a recognisable
* shape.
*/
async function gitRemoteUrl(cwd: string): Promise<string | undefined> {
let raw: string;
try {
const { stdout } = await execFileAsync("git", ["remote", "get-url", "origin"], {
cwd,
timeout: GIT_TIMEOUT_MS,
// 1 KiB is plenty for a URL; protects against weird remote
// names that include the entire output of a hostile hook.
maxBuffer: 1024,
});
raw = stdout.trim();
} catch {
return undefined;
}
if (raw.length === 0) return undefined;
return normalizeRepoUrl(raw);
}
/**
* Normalize a git remote URL to the https form the manifest's `repo`
* field expects. Handles:
*
* git@github.com:foo/bar.git → https://github.com/foo/bar
* git@github.com:foo/bar → https://github.com/foo/bar
* https://github.com/foo/bar.git → https://github.com/foo/bar
* https://github.com/foo/bar → https://github.com/foo/bar
* ssh://git@... → undefined (not auto-rewritten; user can paste)
*
* Returns undefined for shapes we don't recognise; the manifest schema
* requires `https://...` and we'd rather omit the pre-fill than write
* a value the schema will reject.
*/
function normalizeRepoUrl(raw: string): string | undefined {
const sshMatch = GIT_SSH_RE.exec(raw);
if (sshMatch) {
const [, host, path] = sshMatch;
return `https://${host}/${path}`;
}
if (raw.startsWith("https://")) {
return raw.replace(HTTPS_TRAILING_GIT_RE, "");
}
return undefined;
}
// ──────────────────────────────────────────────────────────────────────────
// package.json
// ──────────────────────────────────────────────────────────────────────────
interface PackageJsonDefaults {
license: string | undefined;
description: string | undefined;
repo: string | undefined;
}
const EMPTY_PACKAGE_JSON: PackageJsonDefaults = {
license: undefined,
description: undefined,
repo: undefined,
};
/**
* Read `<targetDir>/package.json` and pull license, description, and
* a normalized repo URL out of it. Returns the empty defaults shape on
* any failure (missing file, parse error, oversized file). The
* scaffolder never trusts this output directly — values flow through
* `EnvironmentDefaults` which the caller may or may not use.
*/
async function readPackageJson(targetDir: string): Promise<PackageJsonDefaults> {
const path = join(targetDir, "package.json");
let raw: string;
try {
raw = await readFile(path, "utf8");
} catch {
return EMPTY_PACKAGE_JSON;
}
if (raw.length > PACKAGE_JSON_MAX_BYTES) {
return EMPTY_PACKAGE_JSON;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return EMPTY_PACKAGE_JSON;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return EMPTY_PACKAGE_JSON;
}
// JSON.parse returns `unknown`; the runtime check above narrows to
// "non-array object", and the cast just makes the property accesses
// below readable. The properties are still typed as `unknown` and
// each one gets its own runtime check.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- narrowed by runtime check above
const pkg = parsed as Record<string, unknown>;
const license = typeof pkg.license === "string" ? pkg.license.trim() : undefined;
const description = typeof pkg.description === "string" ? pkg.description.trim() : undefined;
const repo = repoFromPackageJsonRepository(pkg.repository);
return {
license: license && license.length > 0 ? license : undefined,
description: description && description.length > 0 ? description : undefined,
repo,
};
}
/**
* Pull a repo URL out of package.json#repository. Handles both forms:
*
* "repository": "https://github.com/foo/bar"
* "repository": { "type": "git", "url": "git+https://github.com/foo/bar.git" }
*
* Normalizes through `normalizeRepoUrl`. Returns undefined if the
* value isn't a recognisable string or object-with-url.
*/
function repoFromPackageJsonRepository(value: unknown): string | undefined {
let raw: string | undefined;
if (typeof value === "string") {
raw = value;
} else if (value && typeof value === "object" && !Array.isArray(value)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- narrowed by runtime check above
const url = (value as Record<string, unknown>).url;
if (typeof url === "string") raw = url;
}
if (raw === undefined || raw.length === 0) return undefined;
// npm accepts a leading `git+` on the URL (e.g. `git+https://...`).
// Strip it before normalisation so the https-check passes.
const stripped = raw.replace(GIT_URL_PREFIX_RE, "");
return normalizeRepoUrl(stripped);
}
+156
View File
@@ -0,0 +1,156 @@
/**
* Filesystem half of `emdash-plugin init`. Takes the scaffold inputs +
* the target directory and writes the file tree. Pure templates live in
* `./templates.ts` so this module is just policy: which files exist,
* where they go, what happens when something's already there.
*
* Overwrite policy: refuses by default if any target file exists. Pass
* `--force` to allow overwriting (file-by-file, not directory-wide).
* This avoids the common "I ran init in the wrong dir and clobbered my
* package.json" surprise.
*/
import { access, mkdir, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import {
renderGitignore,
renderManifest,
renderPackageJson,
renderPluginEntry,
renderReadme,
renderTest,
renderTsconfig,
type ScaffoldInputs,
} from "./templates.js";
export type InitErrorCode = "TARGET_FILE_EXISTS" | "INVALID_SLUG" | "INVALID_PUBLISHER";
export class InitError extends Error {
override readonly name = "InitError";
readonly code: InitErrorCode;
/** When set, the list of paths that already exist and would be overwritten. */
readonly conflicts: string[];
constructor(code: InitErrorCode, message: string, conflicts: string[] = []) {
super(message);
this.code = code;
this.conflicts = conflicts;
}
}
export interface ScaffoldOptions {
/** Absolute path to the target directory. Created if it doesn't exist. */
targetDir: string;
/** Validated scaffold inputs. */
inputs: ScaffoldInputs;
/**
* When true, overwrite existing files. When false, refuse with
* `TARGET_FILE_EXISTS` listing the conflicting paths.
*/
force: boolean;
/** Optional callback per file written, for CLI progress output. */
onFileWritten?: (relativePath: string) => void;
}
export interface ScaffoldResult {
/** Absolute paths of every file the scaffolder wrote. */
written: string[];
}
/**
* The file tree the scaffolder produces. Order matters: parents must
* appear before children (the writer creates intermediate dirs from
* the file path, so order is informational rather than mandatory, but
* a consistent order keeps the per-file progress output predictable).
*/
const FILES = [
"emdash-plugin.jsonc",
"package.json",
"tsconfig.json",
".gitignore",
"README.md",
"src/plugin.ts",
"tests/plugin.test.ts",
] as const;
type ScaffoldFile = (typeof FILES)[number];
/**
* Scaffold a plugin into `targetDir`. The target dir is created if it
* doesn't exist; missing intermediate directories under it are created
* per-file as needed.
*
* If any target file already exists and `force` is false, the function
* throws BEFORE writing anything. Partial writes don't happen — either
* every file gets written or none do.
*/
export async function scaffold(options: ScaffoldOptions): Promise<ScaffoldResult> {
const { targetDir, inputs, force, onFileWritten } = options;
const absDir = resolve(targetDir);
// Pre-flight: check for conflicts. We do this before any write so a
// partial scaffold can't leave the target dir in a half-broken state.
if (!force) {
const conflicts: string[] = [];
for (const file of FILES) {
const absPath = join(absDir, file);
if (await exists(absPath)) {
conflicts.push(file);
}
}
if (conflicts.length > 0) {
throw new InitError(
"TARGET_FILE_EXISTS",
`Cannot scaffold into ${absDir}: the following files already exist. Pass --force to overwrite them.\n ${conflicts.join("\n ")}`,
conflicts,
);
}
}
await mkdir(absDir, { recursive: true });
const written: string[] = [];
for (const file of FILES) {
const absPath = join(absDir, file);
await mkdir(dirname(absPath), { recursive: true });
await writeFile(absPath, renderFile(file, inputs), "utf8");
written.push(absPath);
onFileWritten?.(file);
}
return { written };
}
/**
* Dispatch each scaffold-file path to its renderer. Centralised here so
* adding a new file (icon, screenshot stub, docs page) is one place to
* update — append to FILES, add a case.
*/
function renderFile(file: ScaffoldFile, inputs: ScaffoldInputs): string {
switch (file) {
case "emdash-plugin.jsonc":
return renderManifest(inputs);
case "package.json":
return renderPackageJson(inputs);
case "tsconfig.json":
return renderTsconfig();
case ".gitignore":
return renderGitignore();
case "README.md":
return renderReadme(inputs);
case "src/plugin.ts":
return renderPluginEntry();
case "tests/plugin.test.ts":
return renderTest();
}
}
async function exists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
+396
View File
@@ -0,0 +1,396 @@
/**
* Pure file-content producers for `emdash-plugin init`.
*
* No filesystem access here — each function takes the inputs and returns
* the bytes that should land at the target path. Keeping these as pure
* functions makes the scaffolder testable without touching disk and
* keeps every template inspectable in one place.
*
* The shape produced is the authoring contract:
*
* emdash-plugin.jsonc — identity + trust contract + profile
* src/plugin.ts — `{ routes?, hooks? } satisfies SandboxedPlugin`
* package.json — type:module, devDep on @emdash-cms/plugin-cli
* tsconfig.json — strict, standalone
* .gitignore
* README.md
* tests/plugin.test.ts
*
* No `src/index.ts`, no `dist/` in source control. `emdash-plugin build`
* generates `dist/` artefacts (plugin.mjs, manifest.json, index.mjs).
*/
import type { ManifestAuthor, ManifestSecurityContact } from "../manifest/schema.js";
/**
* Inputs to the scaffolder.
*
* Every field except `slug` is optional. Missing fields produce a
* placeholder in the generated manifest — either a `TODO:` line
* comment marking a value the author must fill in before the plugin
* works, or an outright omission of an optional field that the
* schema doesn't require.
*
* The contract: a scaffold produced from `{ slug }` alone is a valid
* starting point that the author can `cd` into, fix the TODOs in,
* and ship. There are no "init failed because you didn't pass enough
* flags" surprises.
*/
export interface ScaffoldInputs {
/** Plugin slug. Used as the directory name and the `slug` field. */
slug: string;
/**
* Pre-filled publisher DID (resolved from a handle if the user
* typed one). When undefined, the manifest carries a TODO comment
* and an empty string; the author must set this before the plugin
* will load.
*
* The runtime only ever compares DIDs, so we write a DID — even if
* the user typed a handle. The handle, when known, is emitted as a
* `// <handle>` line comment next to the pinned DID via
* `publisherHandle` below.
*/
publisher: string | undefined;
/**
* Optional handle that resolved to the `publisher` DID. Rendered as
* a `// <handle>` line comment next to the pinned DID so a `git
* diff` reviewer sees a human-readable name for the publisher. The
* CLI ignores the comment on subsequent reads — only the DID is
* authoritative.
*/
publisherHandle: string | undefined;
/** SPDX license expression. Defaults to "MIT" when undefined. */
license: string | undefined;
/**
* Author block. When undefined, the manifest carries a TODO
* comment and a placeholder name; author.url and author.email
* are omitted from the output entirely (the schema makes them
* optional).
*/
author: ManifestAuthor | undefined;
/**
* Security contact. When undefined, the manifest carries a TODO
* comment and a placeholder email; the author replaces it with
* a real contact before publishing.
*/
security: ManifestSecurityContact | undefined;
/** Optional short description. Omitted from the manifest when undefined. */
description: string | undefined;
/** Optional repo URL. Omitted from the manifest when undefined. */
repo: string | undefined;
}
/**
* `emdash-plugin.jsonc` — the manifest. Includes a `$schema` pointer
* for editor completion. JSONC: tab-indented, no trailing comma on the
* final field. Fields omitted when the input is empty so the generated
* file is the smallest valid manifest, not a sea of `""`.
*/
export function renderManifest(input: ScaffoldInputs): string {
const lines: string[] = [];
lines.push("{");
lines.push(
'\t"$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json",',
);
lines.push("");
lines.push(`\t"slug": ${jsonString(input.slug)},`);
// `version` deliberately omitted — the build reads it from
// `package.json` so there's a single source of truth. Registry-only
// plugins (no package.json) would set it here, but the scaffold
// always emits one.
if (!input.publisher) {
lines.push(
'\t// TODO: set your atproto handle (e.g. "example.com") or DID before running `emdash-plugin bundle` or any local-dev integration. The plugin cannot load without it.',
);
lines.push('\t"publisher": "",');
} else {
// When we know the handle that resolved to this DID, append it
// as a line comment for `git diff` readability. The handle is
// purely informational — the CLI never reads it back.
const trailer = input.publisherHandle ? ` // ${input.publisherHandle}` : "";
lines.push(`\t"publisher": ${jsonString(input.publisher)},${trailer}`);
}
lines.push("");
lines.push(`\t"license": ${jsonString(input.license ?? "MIT")},`);
if (input.author) {
lines.push(`\t"author": ${renderAuthor(input.author)},`);
} else {
lines.push(
"\t// TODO: replace the placeholder with your real name and (optionally) url/email before publishing.",
);
lines.push(
`\t"author": { "name": ${jsonString(`TODO: replace with your name (${input.slug} author)`)} },`,
);
}
if (input.security) {
lines.push(`\t"security": ${renderSecurityContact(input.security)},`);
} else {
lines.push(
"\t// TODO: replace the placeholder with a real security contact email or url before publishing. The lexicon mandates at least one.",
);
lines.push('\t"security": { "email": "TODO@example.com" },');
}
if (input.description) {
lines.push(`\t"description": ${jsonString(input.description)},`);
}
if (input.repo) {
lines.push(`\t"repo": ${jsonString(input.repo)},`);
}
lines.push("");
lines.push("\t// Trust contract — what runtime APIs the plugin asks for.");
lines.push("\t// Empty arrays mean no extra privileges beyond logging,");
lines.push("\t// KV, and route/hook registration. Changing these between");
lines.push("\t// releases requires a version bump because installed");
lines.push("\t// users have consented to the old contract.");
lines.push('\t"capabilities": [],');
lines.push('\t"allowedHosts": [],');
lines.push('\t"storage": {}');
lines.push("}");
lines.push("");
return lines.join("\n");
}
/**
* Render a single author object as a JSONC inline value. Always
* single-line so the generated manifest stays compact.
*/
function renderAuthor(author: ManifestAuthor): string {
const parts: string[] = [`"name": ${jsonString(author.name)}`];
if (author.url) parts.push(`"url": ${jsonString(author.url)}`);
if (author.email) parts.push(`"email": ${jsonString(author.email)}`);
return `{ ${parts.join(", ")} }`;
}
/**
* Render a single security contact as a JSONC inline value.
*/
function renderSecurityContact(contact: ManifestSecurityContact): string {
const parts: string[] = [];
if (contact.email) parts.push(`"email": ${jsonString(contact.email)}`);
if (contact.url) parts.push(`"url": ${jsonString(contact.url)}`);
return `{ ${parts.join(", ")} }`;
}
/**
* `src/plugin.ts` — runtime code. One route, no hooks. Demonstrates the
* two primitives a sandboxed plugin author needs: the strict
* `SandboxedPlugin` type (which infers handler signatures per hook /
* route name) and a default-exported `{ hooks?, routes? }` object.
*/
export function renderPluginEntry(): string {
return `import type { SandboxedPlugin } from "emdash/plugin";
/**
* Sandboxed plugin entry. The default export is a bare object; the
* \`satisfies SandboxedPlugin\` annotation gives TypeScript per-hook /
* per-route inference (\`ctx\` is \`PluginContext\` automatically; hook
* \`event\` parameters are typed by hook name).
*/
export default {
\troutes: {
\t\thello: {
\t\t\thandler: async (_routeCtx, ctx) => {
\t\t\t\tctx.log.info("hello route called", { pluginId: ctx.plugin.id });
\t\t\t\treturn { greeting: "hello", pluginId: ctx.plugin.id };
\t\t\t},
\t\t},
\t},
} satisfies SandboxedPlugin;
`;
}
/**
* `package.json` — npm-shape so the plugin is `pnpm add`-able. The
* scaffold sets `private: true` defensively; flip it off when you're
* ready to publish to npm. `version` here is the single source of
* truth — the build reads it and writes it into the bundled manifest.
*
* `./sandbox` export points at the built runtime bytes that both
* in-process and isolate loaders consume. `main` / `import` point at
* the auto-generated descriptor module the integration imports for
* default in `astro.config.mjs`.
*/
export function renderPackageJson(input: ScaffoldInputs): string {
const pkg = {
name: input.slug,
version: "0.1.0",
private: true,
type: "module",
main: "dist/index.mjs",
exports: {
".": {
import: "./dist/index.mjs",
types: "./dist/index.d.mts",
},
"./sandbox": "./dist/plugin.mjs",
},
files: ["dist", "emdash-plugin.jsonc"],
scripts: {
build: "emdash-plugin build",
dev: "emdash-plugin dev",
typecheck: "tsc --noEmit",
test: "vitest run",
},
peerDependencies: {
emdash: ">=0.12.0",
},
devDependencies: {
"@emdash-cms/plugin-cli": ">=0.1.0",
emdash: ">=0.12.0",
typescript: "^5.9.0",
vitest: "^4.1.0",
},
};
return `${JSON.stringify(pkg, null, "\t")}\n`;
}
/**
* `tsconfig.json` — strict, ES2022, bundler resolution. Mirrors the
* `node22 + bundler` style the rest of the EmDash workspace uses, but
* doesn't extend anything from the workspace so the scaffold is
* self-contained.
*/
export function renderTsconfig(): string {
const config = {
compilerOptions: {
target: "ES2022",
module: "preserve",
moduleResolution: "bundler",
strict: true,
esModuleInterop: true,
verbatimModuleSyntax: true,
skipLibCheck: true,
types: [],
},
include: ["src/**/*", "tests/**/*"],
exclude: ["node_modules"],
};
return `${JSON.stringify(config, null, "\t")}\n`;
}
/**
* `.gitignore` — node_modules + dist (build output should not be
* committed; rebuild on every install).
*/
export function renderGitignore(): string {
return "node_modules/\ndist/\n";
}
/**
* `README.md` — three sections: develop, publish, version-bump rules.
* Nothing else. The author can extend; the scaffold doesn't pre-write
* marketing copy.
*/
export function renderReadme(input: ScaffoldInputs): string {
// The slug is the package title in headings + the import specifier,
// but it can contain hyphens (e.g. `my-plugin`) which aren't legal
// JS identifiers. Derive a camelCase binding name for the import +
// integration call.
const title = input.slug;
const importBinding = toCamelCase(input.slug);
return `# ${title}
A sandboxed plugin for [EmDash CMS](https://emdashcms.com).
## Develop
\`\`\`sh
pnpm install
pnpm typecheck
pnpm test
\`\`\`
To test against a running EmDash site, run \`pnpm dev\` in this
directory (rebuilds on save) and \`pnpm add file:../path/to/this\`
in the site. Then \`import ${importBinding} from "${input.slug}"\` and pass
it into \`emdash({ sandboxed: [${importBinding}] })\`.
## Publish
\`\`\`sh
emdash-plugin login # if you're not already logged in
emdash-plugin bundle # produces dist/${title}-<version>.tar.gz
# upload that tarball to a public URL, then:
emdash-plugin publish --url https://your-host/...
\`\`\`
## Version bumps
Bump \`version\` in \`package.json\` when you ship a release. The
scaffold's \`emdash-plugin.jsonc\` deliberately omits \`version\`
the build pipeline reads it from \`package.json\` so there's a single
source of truth. **Bump major** for breaking changes, **bump minor**
for new routes or hooks, **bump patch** for fixes.
You MUST bump version whenever you change \`capabilities\`, \`allowedHosts\`,
or \`storage\` in the manifest. Installed users have consented to the
old trust contract; a change without a version bump would let new
behaviour slip past consent.
`;
}
/**
* `tests/plugin.test.ts` — one passing test that exercises the
* hello route. Uses a minimal stubbed PluginContext rather than
* pulling in the runtime: the test asserts the handler returns the
* expected shape, not that the runtime wires it up correctly.
*/
export function renderTest(): string {
return `import { describe, expect, it } from "vitest";
import plugin from "../src/plugin.js";
describe("hello route", () => {
\tit("returns a greeting", async () => {
\t\tconst handler = plugin.routes?.hello;
\t\tif (!handler || typeof handler !== "object" || !("handler" in handler)) {
\t\t\tthrow new Error("hello route handler not found");
\t\t}
\t\tconst result = await handler.handler({} as never, makeTestContext());
\t\texpect(result).toEqual({ greeting: "hello", pluginId: "test-plugin" });
\t});
});
function makeTestContext() {
\t// Minimal stub PluginContext: the hello route only reads
\t// \`ctx.log.info\` and \`ctx.plugin.id\`. Real PluginContext has many
\t// more methods; add them as your plugin grows.
\treturn {
\t\tplugin: { id: "test-plugin", version: "0.1.0" },
\t\tlog: {
\t\t\tinfo: () => {},
\t\t\twarn: () => {},
\t\t\terror: () => {},
\t\t\tdebug: () => {},
\t\t},
\t} as unknown as import("emdash").PluginContext;
}
`;
}
/**
* JSON-stringify a string with double quotes and proper escaping.
* Trivially `JSON.stringify` does the job, but wrapping it gives us
* a single place to switch quote styles or escape behaviour later.
*/
function jsonString(value: string): string {
return JSON.stringify(value);
}
const SLUG_SEPARATOR_RE = /[-_]([a-z0-9])/g;
/**
* Convert a plugin slug (`my-plugin`, `my_plugin`) into a JS identifier
* for use as an import binding. Slugs are validated to start with a
* letter (see `PLUGIN_SLUG_RE`), so the result is always a legal
* identifier.
*/
function toCamelCase(slug: string): string {
return slug.replace(SLUG_SEPARATOR_RE, (_, ch: string) => ch.toUpperCase());
}
+481
View File
@@ -0,0 +1,481 @@
/**
* Read and validate an `emdash-plugin.jsonc` manifest from disk.
*
* Failure modes, each with a distinct error code for scriptable consumers
* (`validate --json`, programmatic API users):
*
* - `MANIFEST_NOT_FOUND` — file doesn't exist at the resolved path.
* - `MANIFEST_TOO_LARGE` — file exceeds `MANIFEST_MAX_BYTES`. Reading
* stops at the cap; the file is never fully buffered.
* - `MANIFEST_PARSE_ERROR` — JSONC parse failure (trailing comma, missing
* bracket, control char in string, duplicate keys). Includes line +
* column from `jsonc-parser`'s offset.
* - `MANIFEST_VALIDATION_ERROR` — JSONC parsed cleanly but the value
* failed the Zod schema. Includes the field path and the offending
* value's location in the source where possible.
*
* The line/column mapping is critical for editor-side workflows: a user
* running `emdash-plugin validate` from a CI step wants the same kind of
* pointer they'd get from `tsc` or `eslint`, not a Zod issue tree.
*/
import { open } from "node:fs/promises";
import { resolve } from "node:path";
import { parseTree, type Node, type ParseError, printParseErrorCode } from "jsonc-parser";
import type { ZodIssue } from "zod";
import { ManifestSchema, type Manifest } from "./schema.js";
/**
* Conventional manifest filename. Lives next to the plugin's `package.json`.
*/
export const MANIFEST_FILENAME = "emdash-plugin.jsonc";
/**
* Hard cap on the bytes we'll buffer for a manifest. The largest real-world
* v1 manifest under the current schema is a few hundred bytes; even a heavily-
* populated future version with all the long-form sections from issue #1030
* (5 sections × 20 KB cap each from the lexicon) tops out under 128 KB. We
* pick 1 MiB so accidental mis-targets (`--manifest ./large.tar`) fail fast
* with a clear error rather than OOMing the CLI.
*/
export const MANIFEST_MAX_BYTES = 1024 * 1024;
export type ManifestErrorCode =
| "MANIFEST_NOT_FOUND"
| "MANIFEST_TOO_LARGE"
| "MANIFEST_PARSE_ERROR"
| "MANIFEST_VALIDATION_ERROR";
export class ManifestError extends Error {
override readonly name = "ManifestError";
readonly code: ManifestErrorCode;
/** Resolved absolute path of the manifest file. */
readonly path: string;
/**
* Issues for `MANIFEST_VALIDATION_ERROR`. One per failed rule, each
* carrying a JSON pointer-style path and an optional source location.
* Empty for the other error codes.
*/
readonly issues: ManifestIssue[];
constructor(
code: ManifestErrorCode,
message: string,
path: string,
issues: ManifestIssue[] = [],
) {
super(message);
this.code = code;
this.path = path;
this.issues = issues;
}
}
export interface ManifestIssue {
/** Dotted/bracketed JSON path, e.g. `authors[0].email`. */
path: string;
message: string;
/** 1-indexed line and column in the manifest source, when known. */
location?: { line: number; column: number };
}
export interface LoadManifestResult {
manifest: Manifest;
/** Resolved absolute path. */
path: string;
}
/**
* Load and validate a manifest at `path`. `path` may be a directory (in
* which case `emdash-plugin.jsonc` is appended) or a file.
*
* Throws `ManifestError` on every failure path. Successful return guarantees
* the manifest is schema-valid (but normalisation to the publish-input
* shape still needs `./translate.ts`).
*/
export async function loadManifest(path: string): Promise<LoadManifestResult> {
const resolved = resolve(path);
// Heuristic: paths that end in `.jsonc` or `.json` are treated as
// files; everything else is treated as a directory. We don't `stat`
// to disambiguate because the error path "missing file" should be the
// same regardless of which form the caller passed.
const filePath =
resolved.endsWith(".jsonc") || resolved.endsWith(".json")
? resolved
: resolve(resolved, MANIFEST_FILENAME);
// Bounded read: open the file and read at most MANIFEST_MAX_BYTES+1
// bytes. The extra byte is a sentinel — if we get it, the file is
// definitely over the cap regardless of what `stat` would say.
// This closes the stat-then-readFile race where a concurrent writer
// could grow the file between size check and buffer.
const source = await readBoundedUtf8(filePath);
return parseAndValidate(source, filePath);
}
/**
* Read a UTF-8 file with a hard cap of `MANIFEST_MAX_BYTES` bytes.
* Throws `ManifestError(MANIFEST_TOO_LARGE)` if the file exceeds the cap,
* `ManifestError(MANIFEST_NOT_FOUND)` for ENOENT.
*
* We allocate a buffer of `MANIFEST_MAX_BYTES + 1` and read into it; if
* the read fills the whole buffer, the file is at least one byte over
* the limit and we reject. This avoids the TOCTOU window of a separate
* `stat` call: a concurrent writer can grow the file between syscalls,
* but it can never make our buffer larger than what we allocated up
* front.
*
* `read` returns a single chunk synchronously from kernel buffers when
* available; for files of our cap size (1 MiB) this is one syscall on
* Linux/macOS. We loop in case the kernel returns a short read.
*/
async function readBoundedUtf8(filePath: string): Promise<string> {
let handle: Awaited<ReturnType<typeof open>>;
try {
handle = await open(filePath, "r");
} catch (error) {
if (isNodeNotFoundError(error)) {
throw new ManifestError(
"MANIFEST_NOT_FOUND",
`No manifest at ${filePath}. Create one with: emdash-plugin init`,
filePath,
);
}
throw error;
}
try {
// One extra byte so we can detect oversize without reading
// arbitrarily much.
const buffer = Buffer.allocUnsafe(MANIFEST_MAX_BYTES + 1);
let totalRead = 0;
while (totalRead < buffer.length) {
const { bytesRead } = await handle.read(
buffer,
totalRead,
buffer.length - totalRead,
totalRead,
);
if (bytesRead === 0) break;
totalRead += bytesRead;
}
if (totalRead > MANIFEST_MAX_BYTES) {
throw new ManifestError(
"MANIFEST_TOO_LARGE",
`Manifest at ${filePath} is larger than the ${MANIFEST_MAX_BYTES}-byte cap. Check that you pointed --manifest at the right file.`,
filePath,
);
}
return buffer.subarray(0, totalRead).toString("utf8");
} finally {
await handle.close().catch(() => {
// Closing a handle should never fail in practice; if it
// does, swallow it — the read result is already in hand.
});
}
}
/**
* Variant for callers that already have the source text in hand (tests,
* editor integrations that read the buffer). The `path` argument is used
* for error messages only.
*/
export function parseAndValidateManifest(source: string, path: string): LoadManifestResult {
return parseAndValidate(source, path);
}
// ──────────────────────────────────────────────────────────────────────────
// Internals
// ──────────────────────────────────────────────────────────────────────────
function parseAndValidate(source: string, filePath: string): LoadManifestResult {
const parseErrors: ParseError[] = [];
// `parseTree` gives us both the parsed value AND the syntax tree, so we
// can map a Zod issue's path back to a source offset. `parse` alone
// loses that information.
const root = parseTree(source, parseErrors, {
// Comments are part of the JSONC contract. Trailing commas are
// allowed because they reduce diff noise when the user adds a new
// field at the end.
disallowComments: false,
allowTrailingComma: true,
allowEmptyContent: false,
});
if (parseErrors.length > 0) {
const first = parseErrors[0]!;
const { line, column } = offsetToLineCol(source, first.offset);
throw new ManifestError(
"MANIFEST_PARSE_ERROR",
`${filePath}:${line}:${column}: ${printParseErrorCode(first.error)}`,
filePath,
);
}
if (!root) {
// Shouldn't be reachable when `allowEmptyContent: false` is set and
// `parseErrors` is empty, but `parseTree`'s return type is nullable.
throw new ManifestError("MANIFEST_PARSE_ERROR", `${filePath}: file is empty`, filePath);
}
// Reject duplicate keys before validation. `nodeToValue` is last-wins
// (matches JSON.parse semantics) which silently shadows earlier keys
// — review-hostile for a security-sensitive document like this. We
// scan once for duplicates and surface them as a parse error with a
// source location, so a `git diff` reviewer can't be fooled by a
// "publisher": "<honest>" at the top of the file that gets overridden
// by "publisher": "<hostile>" further down.
const duplicate = findDuplicateKey(root);
if (duplicate) {
const { line, column } = offsetToLineCol(source, duplicate.offset);
throw new ManifestError(
"MANIFEST_PARSE_ERROR",
`${filePath}:${line}:${column}: duplicate key "${duplicate.key}". Each property may only be declared once.`,
filePath,
);
}
const value = nodeToValue(root);
const result = ManifestSchema.safeParse(value);
if (!result.success) {
const issues = result.error.issues.map((issue) => zodIssueToManifestIssue(issue, source, root));
const summary = issues
.map((i) => {
const loc = i.location ? `:${i.location.line}:${i.location.column}` : "";
return `${filePath}${loc}: ${i.path ? `${i.path}: ` : ""}${i.message}`;
})
.join("\n");
throw new ManifestError(
"MANIFEST_VALIDATION_ERROR",
`Manifest validation failed:\n${summary}`,
filePath,
issues,
);
}
return { manifest: result.data, path: filePath };
}
/**
* Map a Zod issue to a manifest issue. The path translation strips the
* leading `$` that some Zod versions prepend and produces the JSONC-style
* `authors[0].email` syntax users will recognise.
*
* Zod 4 types path segments as `PropertyKey` (string | number | symbol).
* Symbols cannot appear in a JSON-parsed value's path (JSON has no symbol
* keys), so we narrow defensively and treat any stray symbol as an
* opaque "<symbol>" string in the displayed path.
*
* Special-cases `unrecognized_keys` (typo'd field names): Zod reports the
* issue at the parent path with the offending key(s) in `issue.keys`.
* Without special handling, the line:col points at the parent object's
* opening brace, not the actual typo. We resolve the first listed key
* inside the parent and use ITS source offset, so a `"licens": "MIT"`
* mistake gets the pointer landing on the bad key's line and column.
*/
function zodIssueToManifestIssue(issue: ZodIssue, source: string, root: Node): ManifestIssue {
const path = narrowZodPath(issue.path);
const pathStr = formatZodPath(path);
let offset: number | undefined;
if (issue.code === "unrecognized_keys") {
const keys = (issue as ZodIssue & { keys?: readonly string[] }).keys;
const firstKey = keys?.[0];
if (firstKey !== undefined) {
const parent = findNodeAtPath(root, path);
if (parent?.type === "object" && parent.children) {
const prop: Node | undefined = parent.children.find(
(c) =>
c.type === "property" &&
c.children?.[0]?.type === "string" &&
c.children[0].value === firstKey,
);
const keyNode = prop?.children?.[0];
if (keyNode) offset = keyNode.offset;
}
}
}
if (offset === undefined) {
offset = findNodeAtPath(root, path)?.offset;
}
const location = offset !== undefined ? offsetToLineCol(source, offset) : undefined;
return location
? { path: pathStr, message: issue.message, location }
: { path: pathStr, message: issue.message };
}
/**
* Coerce a Zod 4 issue path (`PropertyKey[]`) to the string|number form
* the rest of the loader uses. A symbol segment is impossible for JSONC
* input, but we render it defensively rather than crashing.
*/
function narrowZodPath(path: ReadonlyArray<PropertyKey>): Array<string | number> {
return path.map((segment) => {
if (typeof segment === "string" || typeof segment === "number") return segment;
return segment.toString();
});
}
/**
* Format a Zod path array as `authors[0].email`. Numbers become bracketed
* indices; strings become dot-prefixed (except the first).
*/
function formatZodPath(path: ReadonlyArray<string | number>): string {
let out = "";
for (const segment of path) {
if (typeof segment === "number") {
out += `[${segment}]`;
} else {
out += out.length === 0 ? segment : `.${segment}`;
}
}
return out;
}
/**
* Walk the JSONC syntax tree to find the node at a given path. When the
* path traverses into a missing key or a wrong-shape value, returns the
* deepest ancestor that DID exist — so the resulting line:col still
* points at something useful (the parent object, where the missing
* property "should have been"). This matters most for two error
* classes:
*
* - Missing required key: Zod's path is `[key]`, the value doesn't
* exist; returning the root object's offset puts the pointer at the
* opening brace, which an editor highlights as "issue with this
* object".
* - Unknown key (typo): Zod's path is `[wrongKey]`, the value doesn't
* exist in the parent. Same parent-fallback gives the pointer the
* line of the parent object.
*
* Both cases used to return undefined and lose the line:col entirely.
*/
function findNodeAtPath(root: Node, path: ReadonlyArray<string | number>): Node | undefined {
let current: Node | undefined = root;
let lastResolved: Node | undefined = root;
for (const segment of path) {
if (!current) return lastResolved;
if (typeof segment === "number") {
if (current.type !== "array" || !current.children) return current;
const next: Node | undefined = current.children[segment];
if (!next) return current;
current = next;
} else {
if (current.type !== "object" || !current.children) return current;
const prop: Node | undefined = current.children.find(
(c) =>
c.type === "property" &&
c.children?.[0]?.type === "string" &&
c.children[0].value === segment,
);
// `property` node's children are [keyNode, valueNode]. We want
// the value for further traversal. If the property is missing
// entirely (e.g. typo'd key, missing required field), fall
// back to the current object so the caller gets a source
// location for the containing structure.
const next: Node | undefined = prop?.children?.[1];
if (!next) return current;
current = next;
}
lastResolved = current;
}
return current;
}
/**
* Recursively scan an object node for duplicate property names. Returns
* the FIRST duplicate found (innermost-first within the recursion, but
* order across siblings is the order in the source) with its offset for
* line:column reporting.
*
* We scan the entire tree, not just the root: duplicate keys inside
* `author: { ... }` or `security: { ... }` are equally review-hostile.
*/
function findDuplicateKey(node: Node): { key: string; offset: number } | undefined {
if (node.type === "object" && node.children) {
const seen = new Set<string>();
for (const prop of node.children) {
if (prop.type !== "property") continue;
const keyNode = prop.children?.[0];
if (!keyNode || keyNode.type !== "string" || typeof keyNode.value !== "string") {
continue;
}
if (seen.has(keyNode.value)) {
return { key: keyNode.value, offset: keyNode.offset };
}
seen.add(keyNode.value);
const valueNode = prop.children?.[1];
if (valueNode) {
const nested = findDuplicateKey(valueNode);
if (nested) return nested;
}
}
} else if (node.type === "array" && node.children) {
for (const child of node.children) {
const nested = findDuplicateKey(child);
if (nested) return nested;
}
}
return undefined;
}
/**
* Convert a JSONC syntax-tree node to its plain JavaScript value. The
* `parseTree` API doesn't return values directly; this walks the tree.
*
* We can't use `jsonc-parser`'s `parse()` (which would give us the value
* directly) because we need the tree anyway for error-location mapping,
* and parsing twice doubles the work for a file we're about to validate.
*/
function nodeToValue(node: Node): unknown {
switch (node.type) {
case "object": {
const obj: Record<string, unknown> = {};
for (const prop of node.children ?? []) {
if (prop.type !== "property") continue;
const [keyNode, valueNode] = prop.children ?? [];
if (!keyNode || keyNode.type !== "string" || !valueNode) continue;
if (typeof keyNode.value !== "string") continue;
obj[keyNode.value] = nodeToValue(valueNode);
}
return obj;
}
case "array":
return (node.children ?? []).map((child) => nodeToValue(child));
case "string":
case "number":
case "boolean":
case "null":
return node.value;
default:
return undefined;
}
}
/**
* Convert a byte offset in `source` into 1-indexed line + column. Matches
* the convention `tsc` and `eslint` use for error pointers.
*/
function offsetToLineCol(source: string, offset: number): { line: number; column: number } {
let line = 1;
let column = 1;
const max = Math.min(offset, source.length);
for (let i = 0; i < max; i++) {
if (source.charCodeAt(i) === 10 /* \n */) {
line++;
column = 1;
} else {
column++;
}
}
return { line, column };
}
function isNodeNotFoundError(error: unknown): boolean {
return (
error instanceof Error && "code" in error && (error as { code: unknown }).code === "ENOENT"
);
}
@@ -0,0 +1,486 @@
/**
* Verify that the active session matches the manifest's pinned `publisher`,
* and write the publisher back to the manifest on first publish.
*
* Two paths, depending on the manifest state at publish time:
*
* 1. Manifest pins a publisher (DID or handle).
* - DID: compare verbatim against the session DID. Mismatch is an
* immediate, no-override error. The user must `emdash-plugin switch`
* to the right session, or edit the manifest if they're transferring
* the plugin.
* - Handle: resolve to a DID via `@atcute/identity-resolver`, then
* compare. Resolution failures surface as a distinct error code so
* the user can tell "wrong handle" from "wrong account".
* 2. Manifest omits `publisher`.
* - Publish proceeds with the active session.
* - On success, the CLI writes `"publisher": "<session-did>"` back
* to the manifest file using `jsonc-parser`'s `modify` + `applyEdits`
* so comments and formatting are preserved.
*
* The write-back is a post-publish convenience: failures here MUST NOT
* roll back or fail the publish. The publish has already committed to the
* publisher's PDS by this point.
*
* The DID-only write-back rule (we never write a handle) is documented
* in #1028. Hand-written handles are respected verbatim; the user can
* still pin a handle if they prefer the readability.
*/
import { createHash, randomUUID } from "node:crypto";
import { open, rename, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { isDid, isHandle, type Did, type Handle } from "@atcute/lexicons/syntax";
import { applyEdits, modify, parseTree, printParseErrorCode, type ParseError } from "jsonc-parser";
import { createActorResolver } from "../oauth.js";
import { MANIFEST_MAX_BYTES } from "./load.js";
/**
* Result of comparing a manifest's pinned publisher against the active
* session DID. The shape encodes the three downstream cases:
*
* - `match`: publisher pinned, resolved to the session DID. Publish
* proceeds; no write-back.
* - `unpinned`: publisher omitted. Publish proceeds; write-back
* scheduled for after the successful publish.
* - `mismatch`: publisher pinned but doesn't resolve to the session DID.
* Publish refuses; the caller throws.
*/
export type PublisherCheck =
| { kind: "match"; pinnedDid: Did }
| { kind: "unpinned" }
| { kind: "mismatch"; pinnedDid: Did; pinnedDisplay: string };
export type PublisherCheckErrorCode = "MANIFEST_PUBLISHER_UNRESOLVED";
export class PublisherCheckError extends Error {
override readonly name = "PublisherCheckError";
readonly code: PublisherCheckErrorCode;
constructor(code: PublisherCheckErrorCode, message: string) {
super(message);
this.code = code;
}
}
/**
* Compare a manifest's `publisher` value (if any) against the active
* session's DID. Returns a structured outcome rather than throwing on
* mismatch — the caller decides how to render the error so the CLI's
* human + JSON output paths can format consistently.
*
* Throws `PublisherCheckError` only for *failures of the check itself*
* (e.g. the handle couldn't be resolved to a DID). Logical mismatch is
* a successful check result with `kind: "mismatch"`.
*/
export async function checkPublisher(input: {
manifestPublisher: string | undefined;
sessionDid: Did;
}): Promise<PublisherCheck> {
if (input.manifestPublisher === undefined) {
return { kind: "unpinned" };
}
const pinned = input.manifestPublisher;
if (isDid(pinned)) {
if (pinned === input.sessionDid) {
return { kind: "match", pinnedDid: pinned };
}
return { kind: "mismatch", pinnedDid: pinned, pinnedDisplay: pinned };
}
if (isHandle(pinned)) {
const resolved = await resolveHandleToDid(pinned);
if (resolved === input.sessionDid) {
return { kind: "match", pinnedDid: resolved };
}
return { kind: "mismatch", pinnedDid: resolved, pinnedDisplay: pinned };
}
// Should be unreachable: the schema validates the syntax, so an
// invalid value can only reach here when the caller bypassed
// validation. We surface a generic resolver error rather than
// crashing, so the failure path stays consistent.
throw new PublisherCheckError(
"MANIFEST_PUBLISHER_UNRESOLVED",
`publisher value "${pinned}" is neither a DID nor a handle. Edit the manifest to use a valid DID or handle.`,
);
}
/**
* Resolve an atproto handle to a DID via the same actor-resolver the
* OAuth flow uses (DoH + .well-known). Surfaces resolution failures
* with a clear hint pointing the user at the DID-pin escape hatch.
*
* Exported so the `init` command can resolve a handle the user typed
* (or pulled from their active session) before writing it to the
* manifest — same primitive, same failure mode, same error code.
*/
export async function resolveHandleToDid(handle: Handle): Promise<Did> {
const resolver = createActorResolver();
try {
const resolved = await resolver.resolve(handle);
return resolved.did;
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new PublisherCheckError(
"MANIFEST_PUBLISHER_UNRESOLVED",
`could not resolve handle "${handle}" to a DID: ${reason}. ` +
`To avoid the lookup, replace the handle with the DID directly in the manifest (publisher: "did:plc:...").`,
);
}
}
/**
* Write the session DID back to the manifest as the `publisher` field,
* inserting it right after `license` to give a stable canonical order.
*
* The DID is the value the CLI compares against on subsequent publishes;
* the handle (when provided) is appended as a JSONC line comment for
* human readability of `git diff` output. The CLI ignores the comment
* — handle changes don't break the pin, only DID changes do.
*
* Re-reads the source from disk first and re-parses to detect concurrent
* edits. If the file changed (publisher already set, parse errors, or
* the file is gone), the write-back is skipped with a warning rather
* than overwriting the user's edits.
*
* Errors are caught and surfaced as warnings to `onWarn`. The publish
* has already succeeded by the time this runs; a failed write-back must
* not fail the publish.
*/
export async function writePublisherBack(input: {
manifestPath: string;
sessionDid: Did;
/**
* Optional handle of the active session, rendered as a line comment
* next to the inserted DID. The comment is purely informational; the
* CLI never reads it back. Omit for sessions that have no handle
* (e.g. did-only logins).
*/
sessionHandle?: string;
onInfo?: (message: string) => void;
onWarn?: (message: string) => void;
}): Promise<void> {
const { manifestPath, sessionDid, sessionHandle, onInfo, onWarn } = input;
try {
// Bounded read with the same cap the loader uses. If the file
// ballooned between load and write-back (e.g. the user pasted
// in a huge changelog while we were publishing), abort the
// write-back rather than buffering the new contents — the
// post-publish path should never touch a file the loader
// wouldn't have accepted.
const initial = await readManifestBounded(manifestPath);
if (initial.kind === "oversize") {
onWarn?.(
`Skipped writing publisher to ${manifestPath} (file is now larger than the ${MANIFEST_MAX_BYTES}-byte cap; the publish succeeded).`,
);
return;
}
if (initial.kind === "missing") {
onWarn?.(
`Skipped writing publisher to ${manifestPath} (file was removed during publish; the publish succeeded).`,
);
return;
}
const source = initial.source;
// Defensive re-parse: confirm `publisher` is still absent. If
// the user added one while we were publishing, leave their value
// alone. Same if the file no longer parses cleanly. `parseTree`
// is lenient and returns a partial tree on malformed input, so
// we have to inspect the errors array — checking the root's
// type alone misses things like "missing closing brace".
const parseErrors: ParseError[] = [];
const root = parseTree(source, parseErrors, {
disallowComments: false,
allowTrailingComma: true,
allowEmptyContent: false,
});
if (parseErrors.length > 0) {
const first = parseErrors[0]!;
onWarn?.(
`Skipped writing publisher to ${manifestPath} (file no longer parses: ${printParseErrorCode(first.error)}).`,
);
return;
}
if (!root || root.type !== "object") {
onWarn?.(
`Skipped writing publisher to ${manifestPath} (file no longer parses as a JSONC object).`,
);
return;
}
const hasPublisher = root.children?.some(
(prop) =>
prop.type === "property" &&
prop.children?.[0]?.type === "string" &&
prop.children[0].value === "publisher",
);
if (hasPublisher) {
onInfo?.(`Skipped writing publisher to ${manifestPath} (already set by user).`);
return;
}
// `modify` returns a list of text edits; `applyEdits` resolves
// them against the source. This is the JSONC-aware path that
// preserves comments and existing whitespace.
//
// Indentation is sniffed from the user's existing source rather
// than hard-coded. Without this, a 2-space-indented manifest
// gets silently rewritten to tabs on first publish — a
// surprising behaviour for a write-back that's supposed to be
// a small, targeted edit. `getInsertionIndex` places `publisher`
// immediately after `license` (or at the end of the object if
// `license` isn't present, which shouldn't happen for a
// schema-valid manifest but is handled defensively).
const indent = detectIndent(source);
const edits = modify(source, ["publisher"], sessionDid, {
formattingOptions: { insertSpaces: !indent.useTabs, tabSize: indent.size },
getInsertionIndex: (existingProps) => {
const licenseIdx = existingProps.indexOf("license");
if (licenseIdx >= 0) return licenseIdx + 1;
return existingProps.length;
},
});
if (edits.length === 0) {
onWarn?.(
`Skipped writing publisher to ${manifestPath} (no edits produced; file may be malformed).`,
);
return;
}
const applied = applyEdits(source, edits);
// Append a `// <handle>` line comment to the inserted publisher
// line, if we have a handle. The comment is for human readers of
// `git diff`; the CLI itself never parses it back out. We locate
// the inserted line by re-parsing the updated source and looking
// up the `publisher` property node's exact source offset, so a
// DID string that happens to appear elsewhere in the document
// (e.g. in `description`) can't deflect the comment to the
// wrong line.
const updated = sessionHandle ? annotatePublisherLine(applied, sessionHandle) : applied;
// Atomic write: tmpfile + rename. POSIX rename is atomic, so a
// crash mid-write leaves the previous file intact rather than
// truncating the publisher's manifest.
//
// TOCTOU narrowing: re-read the file IMMEDIATELY before rename
// and compare to the bytes we processed. If anything changed
// (editor save, concurrent publish, manual edit), abort the
// write-back. This doesn't eliminate the race — between the
// final read and the rename a writer could still land — but
// it shrinks the window to milliseconds. The publish has
// already succeeded; losing a convenience pin is preferable
// to overwriting a user's edit.
const expectedHash = sha256(source);
const tmp = join(dirname(manifestPath), `.${randomUUID()}.tmp`);
await writeFile(tmp, updated, "utf8");
// Re-read with the same bounded primitive so a file that grew
// past the cap during publish doesn't OOM the verification step.
// Oversize and missing both indicate the file is no longer the
// bytes we hashed; treat them as drift and bail.
//
// Wrap in try/catch so genuinely-unexpected fs failures here
// (EISDIR if the file was replaced with a directory, EACCES,
// etc.) ALSO route through the drift-cleanup path. Otherwise
// the tmpfile we just wrote would leak into the manifest's
// directory because the outer catch handles the failure but
// doesn't know there's a tmpfile to clean up.
let currentHash: string | null;
try {
const current = await readManifestBounded(manifestPath);
currentHash = current.kind === "ok" ? sha256(current.source) : null;
} catch {
currentHash = null;
}
if (currentHash !== expectedHash) {
// File changed under us. Clean up the tmpfile and bail.
await unlinkIgnoreMissing(tmp);
onWarn?.(
`Skipped writing publisher to ${manifestPath} (file changed during publish; no edits made). The publish succeeded; you can pin manually on your next edit.`,
);
return;
}
await rename(tmp, manifestPath);
onInfo?.(`Pinned publisher to ${sessionDid} in ${manifestPath}.`);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
onWarn?.(
`Could not pin publisher to ${manifestPath}: ${reason}. ` +
`The publish succeeded; you can add publisher manually on your next edit.`,
);
}
}
/**
* Discriminated result of a bounded manifest read.
*/
type BoundedReadResult =
| { kind: "ok"; source: string }
| { kind: "oversize" }
| { kind: "missing" };
/**
* Read a manifest with a hard size cap. Returns a discriminated result:
* - `ok` carrying the UTF-8 contents,
* - `oversize` when the file exceeds `MANIFEST_MAX_BYTES`,
* - `missing` for ENOENT.
*
* Bounded variant of the loader's read; same TOCTOU-free pattern (one
* pre-allocated buffer, never grows). We use a discriminated union
* rather than sentinel strings so the type system catches a caller
* that forgets to handle the failure cases.
*/
async function readManifestBounded(filePath: string): Promise<BoundedReadResult> {
let handle: Awaited<ReturnType<typeof open>>;
try {
handle = await open(filePath, "r");
} catch (error) {
if (
error instanceof Error &&
"code" in error &&
(error as { code: unknown }).code === "ENOENT"
) {
return { kind: "missing" };
}
throw error;
}
try {
const buffer = Buffer.allocUnsafe(MANIFEST_MAX_BYTES + 1);
let totalRead = 0;
while (totalRead < buffer.length) {
const { bytesRead } = await handle.read(
buffer,
totalRead,
buffer.length - totalRead,
totalRead,
);
if (bytesRead === 0) break;
totalRead += bytesRead;
}
if (totalRead > MANIFEST_MAX_BYTES) return { kind: "oversize" };
return { kind: "ok", source: buffer.subarray(0, totalRead).toString("utf8") };
} finally {
await handle.close().catch(() => {});
}
}
/**
* Sniff the indentation style used by the source so the write-back can
* match it. Looks at the first indented line and reports whether the
* leading whitespace is tabs or spaces, and the run length.
*
* Falls back to tabs-with-tabSize-1 when:
* - no indented line is found (single-line manifest), or
* - the file is unreadable in a way we can't infer from.
*
* The tab-fallback matches the conventions of the repo's own JSONC files
* (wrangler.jsonc, tsconfig.json, this very package's templates).
*/
function detectIndent(source: string): { useTabs: boolean; size: number } {
const lines = source.split("\n");
for (const line of lines) {
if (line.length === 0) continue;
const first = line[0];
if (first === "\t") return { useTabs: true, size: 1 };
if (first === " ") {
let count = 0;
while (count < line.length && line[count] === " ") count++;
// Indent runs of 1 are weird; round up to 2 as the most
// common non-tab indent. Anything 2-8 we use verbatim.
return { useTabs: false, size: Math.max(2, Math.min(count, 8)) };
}
// Non-whitespace first char → this line isn't indented; keep looking.
}
return { useTabs: true, size: 1 };
}
/** Compute a stable hash of the source bytes, used for TOCTOU narrowing. */
function sha256(source: string): string {
return createHash("sha256").update(source, "utf8").digest("hex");
}
/**
* Remove a file path, treating ENOENT as success. Used to clean up the
* tmpfile when the write-back is aborted post-write but pre-rename.
*/
async function unlinkIgnoreMissing(path: string): Promise<void> {
const { unlink } = await import("node:fs/promises");
try {
await unlink(path);
} catch (error) {
if (
error instanceof Error &&
"code" in error &&
(error as { code: unknown }).code === "ENOENT"
) {
return;
}
// Anything else is also non-fatal here (the tmpfile is best-
// effort cleanup); we swallow it so the outer warn message
// stays the dominant signal.
}
}
/**
* Append `// <handle>` to the line containing the inserted `publisher`
* property's value.
*
* The implementation re-parses the updated source, locates the
* `publisher` property's value node by name (NOT by string-matching the
* DID), and uses that node's source offset as the anchor. Two reasons
* for the re-parse rather than a raw string search:
*
* 1. A DID string can legitimately appear elsewhere in the manifest
* (e.g. as the value of `description` or a custom comment-like
* field). String search would attach the comment to the first
* occurrence, not the inserted property.
* 2. The parse tree gives us byte-exact offsets, so the line-end
* lookup can't degrade silently when `indexOf` returns -1.
*
* If the `publisher` property can't be located (unexpected — we just
* inserted it), returns the input unchanged. Annotation is a nice-to-
* have; the publish has already succeeded.
*
* No sanitisation of the handle is needed: `session.handle` is
* populated by atproto's identity resolver at login time, which only
* accepts values that (a) match the handle syntax (no control chars,
* no `/`, no `*`, no whitespace) and (b) round-trip via DoH or
* `.well-known` to the session DID. An attacker who can put arbitrary
* bytes into `session.handle` already controls the user's identity.
*/
function annotatePublisherLine(source: string, handle: string): string {
if (handle.length === 0) return source;
const tree = parseTree(source);
if (!tree || tree.type !== "object") return source;
const publisherProp = tree.children?.find(
(prop) =>
prop.type === "property" &&
prop.children?.[0]?.type === "string" &&
prop.children[0].value === "publisher",
);
const valueNode = publisherProp?.children?.[1];
if (!valueNode) return source;
// Find the end of the line containing the value's last byte. The
// value's offset+length lands right after the closing `"` of the
// DID string; `indexOf("\n", endOfValue)` walks forward to the
// newline that terminates the property's line. The intervening
// bytes can be `,`, whitespace, or already-existing comment text.
const endOfValue = valueNode.offset + valueNode.length;
const lineEnd = source.indexOf("\n", endOfValue);
if (lineEnd < 0) {
// `publisher` is on the last line of the file with no trailing
// newline. Append the comment to the end-of-file content.
return `${source} // ${handle}`;
}
// Walk back past any trailing CR so the comment lands at the end
// of the *content*, not after a literal "\r" on Windows-authored
// files.
let insertAt = lineEnd;
if (insertAt > 0 && source[insertAt - 1] === "\r") insertAt -= 1;
return `${source.slice(0, insertAt)} // ${handle}${source.slice(insertAt)}`;
}
+974
View File
@@ -0,0 +1,974 @@
/**
* Zod schema for `emdash-plugin.jsonc` — the publisher-authored manifest that
* sits next to a plugin's source and feeds the registry CLI's `publish`,
* `validate`, and `init` commands.
*
* Relationship to the lexicon
* ---------------------------
*
* This schema is NOT the lexicon. The lexicon
* (`com.emdashcms.experimental.package.profile`) is the on-wire atproto
* record format, optimised for content-addressed storage and aggregator
* indexing. This schema is the authoring format, optimised for a human
* editing a file in VS Code with `$schema`-powered IDE completion.
*
* Fields that exist in BOTH places use the lexicon's field names verbatim
* (`license`, `keywords`, `repo`, `name`, `description`). Fields that the
* publisher cannot reasonably write by hand are derived at publish time and
* do not appear here: `id` (full AT URI requires the publisher's DID),
* `type` (always `"emdash-plugin"` from this CLI), `slug` (derived from the
* bundled `manifest.json`'s `id`), `lastUpdated` (set at publish time),
* `artifacts.package` (filled in from the fetched tarball), `extensions`
* (computed from the bundled manifest's capabilities + allowedHosts).
*
* The translation step lives in `./translate.ts`.
*
* Single-vs-multi-author convenience
* ----------------------------------
*
* The lexicon stores `authors` and `security` as arrays. The overwhelmingly
* common case is one author and one security contact, so the manifest
* accepts both shapes:
*
* // single-author
* { "author": { "name": "Jane Doe" }, "security": { "email": "..." } }
*
* // multi-author
* { "authors": [{ "name": "..." }, { "name": "..." }],
* "securityContacts": [{ "email": "..." }] }
*
* `loadManifest` normalises both forms to the array shape before passing to
* publish. You can't mix forms for the same field (e.g. `author` AND
* `authors`); the schema rejects that.
*
* Strict mode
* -----------
*
* Unknown keys are rejected with `.strict()`. This catches typos like
* `"licens": "MIT"` rather than letting them silently fall through. The
* tradeoff is that adding a field requires a CLI release; we accept that
* cost for v1 and may revisit after one cycle of field-add (issue #1029).
*/
import { isDid, isHandle } from "@atcute/lexicons/syntax";
import {
CAPABILITY_RENAMES,
isDeprecatedCapability,
normalizeCapability,
PLUGIN_SLUG_MAX_LENGTH,
PLUGIN_SLUG_RE,
PLUGIN_VERSION_MAX_LENGTH,
PLUGIN_VERSION_RE,
} from "@emdash-cms/plugin-types";
import { isValidVersionRange } from "@emdash-cms/registry-client/env";
import { z } from "zod";
// ──────────────────────────────────────────────────────────────────────────
// Field-level schemas — exported so tests can target individual rules.
//
// Each field uses `.meta({ description })` so the descriptions flow into
// the generated JSON Schema and surface as inline hover hints in editors
// that support `$schema`-driven completion (VS Code, IntelliJ).
// ──────────────────────────────────────────────────────────────────────────
/**
* SPDX license expression. The lexicon caps this at 256 chars. We don't
* validate the SPDX grammar here — the registry aggregator does that and
* gives clearer errors. We DO refuse the empty string and obvious garbage
* (whitespace-only) so the publish command can surface a useful message
* before any network round-trip.
*/
export const LicenseSchema = z
.string()
.min(1, 'license must be a non-empty SPDX expression (e.g. "MIT")')
.max(256, "license must be <= 256 characters (SPDX expressions are short)")
.refine((v) => v.trim().length > 0, "license cannot be whitespace-only")
.meta({
title: "License",
description:
'SPDX license expression (e.g. "MIT", "Apache-2.0", "MIT OR Apache-2.0"). Required on first publish; ignored on subsequent publishes (the existing profile wins).',
examples: ["MIT", "Apache-2.0", "MIT OR Apache-2.0"],
});
/**
* One author. Mirrors `profile.json#author`. The lexicon says authors
* "SHOULD specify at least one of url or email"; we don't enforce that
* here because anonymous-but-named authors are a legitimate (if
* discouraged) shape. The publish command surfaces it as a warning.
*/
export const AuthorSchema = z
.object({
name: z
.string()
.min(1, "author.name cannot be empty")
.max(256, "author.name must be <= 256 characters")
.meta({ description: "Display name." }),
url: z
.string()
.url("author.url must be a valid URL")
.max(1024, "author.url must be <= 1024 characters")
.meta({
description: "Author's homepage or profile URL. Either this or `email` is recommended.",
})
.optional(),
email: z
.string()
.email("author.email must be a valid email")
.max(256, "author.email must be <= 256 characters")
.meta({ description: "Author's contact email. Either this or `url` is recommended." })
.optional(),
})
.strict()
.meta({
title: "Author",
description: "A single author entry. Mirrors the lexicon's author shape.",
});
/**
* One security contact. Mirrors `profile.json#contact`. The lexicon
* mandates "at least one of url or email MUST be present"; Lexicon JSON
* can't express "required one-of", so we enforce it here. Without this
* check a publisher could write `{ "security": {} }` and the publish
* record would carry an empty contact (which aggregators reject anyway,
* but failing here is a better user experience).
*/
export const SecurityContactSchema = z
.object({
url: z
.string()
.url("security.url must be a valid URL")
.max(1024, "security.url must be <= 1024 characters")
.meta({
description:
"Security disclosure URL (e.g. a security.txt or vulnerability-reporting page). Either this or `email` is required.",
})
.optional(),
email: z
.string()
.email("security.email must be a valid email")
.max(256, "security.email must be <= 256 characters")
.meta({
description: "Security contact email. Either this or `url` is required.",
})
.optional(),
})
.strict()
.refine(
(v) => v.url !== undefined || v.email !== undefined,
"security contact must have at least one of `url` or `email`",
)
.meta({
title: "Security contact",
description: "A single security contact. At least one of `url` or `email` must be present.",
});
/**
* Publisher identity, used to verify the active session matches the
* manifest's pinned publisher at publish time. Accepts a DID or a handle.
*
* Recommended form: DID (`did:plc:...`). DIDs are durable — they survive
* handle changes. Handles are friendlier to read but mutable: if the
* publisher's handle changes, the manifest needs an update.
*
* Omitted on first publish, the CLI writes the active session's DID
* back into the manifest automatically. Subsequent publishes verify
* against the pinned value.
*
* Validation is structural only here: DID syntax (`did:method:id`) or
* handle syntax (`name.tld`). The actual resolve-to-DID step happens at
* publish time via `@atcute/identity-resolver`.
*/
export const PublisherSchema = z
.string()
.refine(
(v) => isDid(v) || isHandle(v),
'publisher must be an atproto DID (e.g. "did:plc:abc123") or handle (e.g. "example.com")',
)
.meta({
title: "Publisher",
description:
"Atproto DID or handle of the publishing identity. Pinned on first publish to prevent accidental publishes from a different account. DIDs are recommended (durable); handles work but are mutable.",
examples: ["did:plc:abc123def456", "example.com"],
});
/** Optional human-readable display name. Mirrors `profile.json#name`. */
export const NameSchema = z
.string()
.min(1, "name cannot be empty when set")
.max(1024, "name must be <= 1024 characters")
.meta({
title: "Display name",
description:
"Human-readable name shown in directory listings. Defaults to the plugin's `id` when omitted.",
});
/** Short description. Mirrors `profile.json#description`. */
export const DescriptionSchema = z
.string()
.min(1, "description cannot be empty when set")
.max(1024, "description must be <= 1024 characters")
.meta({
title: "Description",
description:
"Short description (<= 140 graphemes by FAIR convention). Aggregators may truncate longer values when displaying in compact lists.",
});
/** Search keywords. Mirrors `profile.json#keywords`. */
export const KeywordsSchema = z
.array(
z.string().min(1, "keyword cannot be empty").max(128, "each keyword must be <= 128 characters"),
)
.max(5, "keywords array must have <= 5 entries (FAIR convention)")
.meta({
title: "Keywords",
description: "Search keywords (<= 5 entries, FAIR convention).",
});
/**
* Source repository URL. Mirrors `release.json#repo`. The lexicon accepts
* either an HTTPS URL or an AT URI; v1 of the CLI accepts HTTPS only.
* AT-URI source repos can be added in a later issue without changing the
* field name.
*
* We use a regex `pattern` rather than `.refine` for the https-only rule
* so the constraint flows through to the generated JSON Schema. Editors
* doing client-side validation against the schema then surface the same
* error the CLI does.
*/
export const RepoSchema = z
.string()
.regex(/^https:\/\//, "repo must be an https:// URL (AT-URI source repos aren't supported yet)")
.url("repo must be a valid URL")
.max(1024, "repo must be <= 1024 characters")
.meta({
title: "Source repository",
description: "HTTPS URL of the plugin's source repository. Surfaced in registry listings.",
examples: ["https://github.com/emdash-cms/plugin-gallery"],
});
/** `env:<name>` requirement keys (one or more non-colon characters). */
const REQUIRES_ENV_KEY_RE = /^env:[^:]+$/;
/**
* Release-level environment constraints. Mirrors `release.json#requires`: a
* map of `env:*` keys (host environment requirements) or package DIDs to
* semver-range constraint strings. EmDash uses `env:emdash` and `env:astro`.
*
* Keys are validated structurally (`env:<name>` or `did:<method>:<id>`) and
* values against the shared range grammar in `@emdash-cms/registry-client/env`,
* the same evaluator the install gate and the admin compatibility warning use,
* so a publisher can't ship a constraint that no consumer can evaluate.
*/
export const RequiresSchema = z
.record(
z
.string()
.refine(
(k) => REQUIRES_ENV_KEY_RE.test(k) || isDid(k),
'requires key must be an `env:*` requirement (e.g. "env:astro") or a package DID',
),
z
.string()
.min(1, "requires range must be a non-empty semver range")
.refine(
isValidVersionRange,
'requires range must be a valid semver range (e.g. ">=4.16", "^4.0.0", ">=4.16.0 <5.0.0")',
),
)
.meta({
title: "Environment requirements",
description:
'Host environment constraints for this release, keyed by `env:*` (e.g. "env:astro", "env:emdash") with semver-range values. EmDash refuses to install a release whose constraints the host does not satisfy.',
examples: [{ "env:emdash": ">=1.0.0", "env:astro": ">=4.16" }],
});
// ──────────────────────────────────────────────────────────────────────────
// Identity (slug + version)
// ──────────────────────────────────────────────────────────────────────────
/**
* The plugin's slug. ASCII letter then letters/digits/hyphens/underscores,
* max 64 chars. Same constraints as the registry lexicon's `rkey`-portion
* of a release record, validated via the shared `PLUGIN_SLUG_RE` in
* `@emdash-cms/plugin-types`.
*
* Slug + publisher together form the package identity. The runtime derives
* the AT URI from them; the author never writes the URI directly.
*/
export const SlugSchema = z
.string()
.min(1, "slug must be a non-empty string")
.max(PLUGIN_SLUG_MAX_LENGTH, `slug must be <= ${PLUGIN_SLUG_MAX_LENGTH} characters`)
.regex(
PLUGIN_SLUG_RE,
'slug must start with a lowercase letter, then lowercase letters / digits / "-" / "_" (e.g. "gallery", "my-plugin")',
)
.meta({
title: "Slug",
description:
"URL-safe plugin identifier within the publisher's namespace. ASCII letter then letters/digits/hyphens/underscores, max 64 characters. Combined with the publisher DID, this is the registry's primary key.",
examples: ["gallery", "image-resizer", "my-plugin"],
});
/**
* The plugin's version. Subset of semver 2.0; build-metadata (`+...`) is
* disallowed because atproto record keys can't contain `+`. Validated via
* `PLUGIN_VERSION_RE` from `@emdash-cms/plugin-types`.
*/
export const VersionSchema = z
.string()
.min(1, "version must be a non-empty string")
.max(PLUGIN_VERSION_MAX_LENGTH, `version must be <= ${PLUGIN_VERSION_MAX_LENGTH} characters`)
.regex(
PLUGIN_VERSION_RE,
'version must follow semver 2.0 without build-metadata (e.g. "0.1.0", "1.2.3-rc.1")',
)
.meta({
title: "Version",
description:
"Plugin version. Semver 2.0 subset; build-metadata `+...` is disallowed (the atproto record-key alphabet has no `+`). Bumped on every release.",
examples: ["0.1.0", "1.2.3", "1.0.0-rc.1"],
});
// ──────────────────────────────────────────────────────────────────────────
// Trust contract (capabilities + allowedHosts + storage)
// ──────────────────────────────────────────────────────────────────────────
/**
* The set of currently-valid (non-deprecated) capability names.
*
* Mirrors the `CurrentPluginCapability` union from `@emdash-cms/plugin-types`.
* TS unions don't survive erasure into a runtime Set, so we maintain the
* list here and the schema's tests catch drift against the type definition.
*/
const CURRENT_CAPABILITIES = new Set<string>([
"network:request",
"network:request:unrestricted",
"content:read",
"content:write",
"taxonomies:read",
"media:read",
"media:write",
"users:read",
"email:send",
"hooks.email-transport:register",
"hooks.email-events:register",
"hooks.page-fragments:register",
]);
/**
* A single capability declaration. Plain string, validated for membership
* in the current vocabulary AND for being non-deprecated. Deprecated names
* are hard-rejected with a hint pointing at the replacement — the deprecation
* window is for already-published plugins, not for new authoring.
*
* Uses a single `superRefine` so we can produce an issue-specific message
* that names the offending capability string. The shape mirrors Zod 4's
* recommended pattern for "value-dependent error messages".
*/
export const CapabilitySchema = z
.string()
.min(1, "capability must be a non-empty string")
.superRefine((cap, ctx) => {
if (isDeprecatedCapability(cap)) {
const replacement = CAPABILITY_RENAMES[cap];
ctx.addIssue({
code: "custom",
message: `capability "${cap}" is deprecated. Use "${replacement}" instead.`,
});
return;
}
const normalised = normalizeCapability(cap);
if (!CURRENT_CAPABILITIES.has(normalised)) {
ctx.addIssue({
code: "custom",
message: `capability "${cap}" is not a recognised name. See the docs for the available capabilities.`,
});
}
});
/**
* Capabilities array. The plugin's declared trust contract. Empty array
* (or omitted field, defaulting to empty) means the plugin asks for no
* privileges beyond the built-in surface (logging, kv, routes/hooks
* registration).
*
* Cross-field rule (in `ManifestSchema`'s `.refine()`): if `capabilities`
* includes `network:request` (and NOT `network:request:unrestricted`),
* then `allowedHosts` must be a non-empty array. This matches the
* `releaseExtension` lexicon's `networkRequestConstraints.allowedHosts`
* "absent OR non-empty" rule.
*/
export const CapabilitiesSchema = z
.array(CapabilitySchema)
.max(32, "capabilities[] must have <= 32 entries")
.meta({
title: "Capabilities",
description:
"Trust contract: what runtime APIs the plugin is allowed to use. Changing this between releases requires a version bump because installed users have consented to the old contract.",
});
/**
* Slash or whitespace in a hostname pattern is a sign the user pasted a
* URL or path instead of a bare host. Hoisted out of `.refine()` so the
* regex is compiled once.
*/
const HOST_PATTERN_INVALID_CHARS = /[/\s]/;
/**
* Allowed-hosts list for `network:request`. Each entry is a hostname
* pattern with no scheme/path/whitespace; a leading `*.` permits
* subdomains. (Ports are accepted by this loose check; the publish-time
* lexicon validator is the strict authority on the exact grammar.)
*/
export const AllowedHostsSchema = z
.array(
z
.string()
.min(1, "host pattern must be non-empty")
.max(256, "host pattern must be <= 256 characters")
.refine(
(h) => !HOST_PATTERN_INVALID_CHARS.test(h) && !h.includes("://"),
'host pattern must be a hostname only (no scheme, path, or whitespace; "*." for subdomain wildcard is allowed)',
),
)
.max(64, "allowedHosts[] must have <= 64 entries")
.meta({
title: "Allowed hosts",
description:
"Allow-list of outbound host patterns when `network:request` is declared. Subdomain wildcards use a leading `*.`. Required (non-empty) when `network:request` is declared without `network:request:unrestricted`.",
examples: [["api.example.com", "*.cdn.example.com"]],
});
/**
* Storage collection config. Mirrors `StorageCollectionConfig` from
* `@emdash-cms/plugin-types`. Indexes are field names (or composite
* arrays). Unique indexes are queryable too — don't duplicate them in
* `indexes`.
*/
export const StorageCollectionSchema = z
.object({
indexes: z.array(z.union([z.string().min(1), z.array(z.string().min(1)).min(1)])),
uniqueIndexes: z
.array(z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]))
.optional(),
})
.strict()
.meta({
title: "Storage collection",
description:
"Index configuration for a single storage collection. Indexes are either single field names or composite (array of field names).",
});
/**
* Storage declaration. Map of collection name to its index config.
* Collection names follow the same slug-like rules as plugin slugs:
* lowercase letters, digits, hyphens, underscores. The runtime uses the
* collection name verbatim as the SQL table-suffix, so the grammar must
* be safe.
*/
export const StorageSchema = z
.record(
z
.string()
.min(1, "storage collection name must be non-empty")
.regex(
/^[a-z][a-z0-9_]*$/,
'storage collection name must start with a lowercase letter, then lowercase letters / digits / "_"',
),
StorageCollectionSchema,
)
.meta({
title: "Storage",
description:
"Storage collections the plugin uses. Each collection is namespaced to this plugin at runtime.",
});
// ──────────────────────────────────────────────────────────────────────────
// Admin surface (pages + dashboard widgets)
// ──────────────────────────────────────────────────────────────────────────
/**
* An admin page declaration. Sandboxed plugins render admin pages
* through Block Kit via their `admin` route handler — the manifest
* just declares where the page lives in the navigation, what it's
* called, and what icon goes next to it.
*
* Path is restricted to a leading slash + URL-safe characters so the
* admin router has a sensible space of values to dispatch on.
*/
export const AdminPageSchema = z
.object({
path: z
.string()
.min(2, "admin page path must be at least 2 characters (leading slash + name)")
.max(128, "admin page path must be <= 128 characters")
.regex(
/^\/[a-z0-9][a-z0-9/_-]*$/i,
'admin page path must start with "/" and contain only letters, digits, "-", "_", "/"',
),
label: z
.string()
.min(1, "admin page label cannot be empty")
.max(128, "admin page label must be <= 128 characters"),
icon: z.string().min(1).max(64).optional(),
})
.strict()
.meta({
title: "Admin page",
description:
"A single admin page declaration. The plugin's `admin` route handler is responsible for rendering Block Kit content for this path.",
});
/**
* A dashboard widget declaration. Same surface contract as admin
* pages — the plugin's `admin` route handler renders the widget's
* Block Kit content, scoped by widget id.
*/
export const AdminWidgetSchema = z
.object({
id: z
.string()
.min(1, "admin widget id cannot be empty")
.max(64, "admin widget id must be <= 64 characters")
.regex(
/^[a-z][a-z0-9_-]*$/,
'admin widget id must start with a lowercase letter, then lowercase letters / digits / "-" / "_"',
),
title: z.string().min(1).max(128).optional(),
size: z.enum(["full", "half", "third"]).optional(),
})
.strict()
.meta({
title: "Admin widget",
description: "A single dashboard widget declaration.",
});
/**
* Admin surface block in the manifest. Both fields are optional;
* plugins that don't expose admin UI at all simply omit the `admin`
* key entirely.
*/
export const AdminSchema = z
.object({
pages: z.array(AdminPageSchema).max(32, "admin.pages[] must have <= 32 entries").optional(),
widgets: z
.array(AdminWidgetSchema)
.max(32, "admin.widgets[] must have <= 32 entries")
.optional(),
})
.strict()
.meta({
title: "Admin surface",
description:
"Pages and widgets the plugin exposes in the admin UI. The plugin's `admin` route handler renders Block Kit content for each path / widget id at runtime.",
});
// ──────────────────────────────────────────────────────────────────────────
// Long-form profile sections (description / installation / faq / changelog /
// security)
// ──────────────────────────────────────────────────────────────────────────
/** Per-section size caps, mirroring `profile.json#sections`. */
export const SECTION_MAX_BYTES = 20000;
export const SECTION_MAX_GRAPHEMES = 2000;
/** The five FAIR-recognised section keys the lexicon enumerates. */
export const SECTION_KEYS = [
"description",
"installation",
"faq",
"changelog",
"security",
] as const;
export type SectionKey = (typeof SECTION_KEYS)[number];
const sectionGraphemeSegmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
/** Grapheme count via `Intl.Segmenter` — the codebase's grapheme-aware measure. */
export function countGraphemes(value: string): number {
let count = 0;
for (const _ of sectionGraphemeSegmenter.segment(value)) count++;
return count;
}
/**
* Enforce the per-section size caps on a resolved markdown string. Returns
* an error message when over a cap, or `null` when within both. Shared by the
* inline-string schema check and the load-time file-ref check so an inline
* section and a `{ file }` section fail with the same message.
*/
export function sectionCapError(value: string): string | null {
const bytes = Buffer.byteLength(value, "utf8");
if (bytes > SECTION_MAX_BYTES) {
return `content is ${bytes} bytes, exceeding the ${SECTION_MAX_BYTES}-byte cap`;
}
const graphemes = countGraphemes(value);
if (graphemes > SECTION_MAX_GRAPHEMES) {
return `content is ${graphemes} graphemes, exceeding the ${SECTION_MAX_GRAPHEMES}-grapheme cap`;
}
return null;
}
/**
* A single section file reference. The `file` path is resolved relative to the
* manifest at LOAD time; the CLI reads the file's content directly into the
* section string (sections are inlined into the profile record, unlike media
* artifacts whose bytes are uploaded). Only the authoring input lives here.
*/
export const SectionFileSchema = z
.object({
file: z
.string()
.min(1, "section `file` path cannot be empty")
.max(1024, "section `file` path must be <= 1024 characters")
.meta({
description:
"Path to a CommonMark Markdown file, relative to the manifest. Its content is read inline into the section at publish time.",
}),
})
.strict()
.meta({
title: "Section file reference",
description:
"Loads the section's Markdown from a sibling file instead of inlining it in the manifest.",
});
/**
* A single section value: either an inline CommonMark string or a `{ file }`
* reference. Inline strings are capped here (20000 bytes / 2000 graphemes); a
* file ref's content is capped at load time once the file is read, because the
* bytes aren't known until then.
*/
export const SectionValueSchema = z
.union([
z
.string()
.superRefine((value, ctx) => {
const error = sectionCapError(value);
if (error) ctx.addIssue({ code: "custom", message: error });
})
.meta({
description:
"Inline CommonMark Markdown for this section. Capped at 20000 bytes / 2000 graphemes.",
}),
SectionFileSchema,
])
.meta({
title: "Section value",
description:
"Either inline CommonMark Markdown or a `{ file }` reference to a sibling Markdown file.",
});
/**
* Long-form profile sections. Profile-level (not per-release): the same map of
* human-readable Markdown carries across every release of a package. Keys are
* the five FAIR-recognised section names; `.strict()` rejects any other key so
* a typo (`instalation`) fails locally rather than producing an unrecognised
* section in the published profile.
*/
export const SectionsSchema = z
.object({
description: SectionValueSchema.optional(),
installation: SectionValueSchema.optional(),
faq: SectionValueSchema.optional(),
changelog: SectionValueSchema.optional(),
security: SectionValueSchema.optional(),
})
.strict()
.meta({
title: "Profile sections",
description:
"Long-form package documentation (description / installation / faq / changelog / security). Each value is CommonMark Markdown (inline or a `{ file }` ref) capped at 20000 bytes / 2000 graphemes. Rendered on the package's registry page.",
});
// ──────────────────────────────────────────────────────────────────────────
// Media artifacts (icon / screenshot / banner)
// ──────────────────────────────────────────────────────────────────────────
/**
* BCP 47 language tag for a localised artifact. Mirrors `release.json#artifact.lang`.
* Structural check only — the registry aggregator owns the strict grammar.
*/
const ArtifactLangSchema = z
.string()
.min(2, 'lang must be a BCP 47 language tag (e.g. "en", "pt-BR")')
.max(64, "lang must be <= 64 characters")
.meta({
description: 'BCP 47 language tag for a localised artifact (e.g. "en", "pt-BR").',
examples: ["en", "pt-BR"],
});
/**
* A single media-artifact file reference. The `file` path is resolved relative
* to the manifest at publish time; the CLI reads the bytes, computes the
* checksum and pixel dimensions, uploads them to the publisher's artifact
* hosting, and writes a `#artifact` record (url, checksum, contentType, width,
* height, lang?) into the release. Only the authoring inputs live here — the
* derived fields never appear in the manifest.
*/
export const ArtifactFileSchema = z
.object({
file: z
.string()
.min(1, "artifact `file` path cannot be empty")
.max(1024, "artifact `file` path must be <= 1024 characters")
.meta({
description:
"Path to the image file, relative to the manifest. Resolved, hashed, measured, and uploaded at publish time.",
}),
lang: ArtifactLangSchema.optional(),
})
.strict()
.meta({
title: "Artifact file reference",
description:
"A media file (PNG / JPEG / WebP / GIF / AVIF) bundled into a release as an icon, screenshot, or banner.",
});
/**
* Release media artifacts. `icon` and `banner` are single files; `screenshots`
* is an array (a plugin can ship a gallery). Mirrors `release.json#artifacts`
* minus the `package` entry, which the CLI derives from the tarball.
*/
export const ArtifactsSchema = z
.object({
icon: ArtifactFileSchema.optional(),
banner: ArtifactFileSchema.optional(),
screenshots: z
.array(ArtifactFileSchema)
.min(1, "screenshots[] must have at least one entry when set")
.max(8, "screenshots[] must have <= 8 entries")
.meta({
title: "Screenshots",
description: "Screenshot gallery for the plugin's detail page (<= 8 entries).",
})
.optional(),
})
.strict()
.meta({
title: "Artifacts",
description:
"Release media artifacts. `icon` and `banner` are single images; `screenshots` is a gallery array.",
});
/**
* Release-level block. Holds fields scoped to a single version rather than the
* package profile. Today that's media `artifacts`; the source `repo` stays at
* the top level for backwards compatibility.
*/
export const ReleaseSchema = z
.object({
requires: RequiresSchema.optional(),
artifacts: ArtifactsSchema.optional(),
})
.strict()
.meta({
title: "Release",
description:
"Per-release fields: environment constraints (`requires`) and media artifacts (icon / screenshot / banner).",
});
// ──────────────────────────────────────────────────────────────────────────
// Top-level manifest
// ──────────────────────────────────────────────────────────────────────────
/**
* The full v1 manifest. Unknown keys are rejected by `.strict()` so a
* typo'd field name produces an immediate error rather than passing
* through silently. The cost is that every later issue (#1029, #1030, ...)
* has to extend this schema, which is intentional: the manifest is a
* contract with users and we want changes to be deliberate.
*
* `$schema` is allowed because editors write it automatically for IDE
* completion. It is stripped before validation passes the value to the
* publish translation.
*/
export const ManifestSchema = z
.object({
// `$schema` is for editor IDE support and the JSON Schema tooling
// chain. It carries no semantic meaning to publish; the loader
// strips it before handing the value off.
$schema: z
.string()
.meta({
description:
"Path or URL to the JSON Schema describing this file. Editors use this for completion and validation.",
})
.optional(),
// Identity. Slug + publisher together form the package's identity;
// the AT URI is derived at runtime, never authored.
slug: SlugSchema,
// Version is optional in the source manifest. `package.json#version`
// is the canonical source for npm-distributed plugins (Changesets
// bumps it on release), so duplicating it here causes drift. Two
// authoring shapes are valid:
// - Omit `version` in the manifest, keep it only in `package.json`.
// - Set it in both, in which case the build step enforces they
// match and errors loudly on mismatch.
// Registry-only plugins (no `package.json`) must set `version` here
// — there's nowhere else for it to live.
version: VersionSchema.optional(),
// Required on first publish, ignored on subsequent publishes (the
// existing profile wins). Same precedence rules as today's
// --license flag.
license: LicenseSchema,
// Publisher pin. Required for the plugin to load — the runtime
// can't compute the AT URI without it. Authors fill it in before
// first run; on first publish, if the value matches the session,
// it stays. If a publisher migrates the manifest's `publisher`
// must be updated explicitly.
publisher: PublisherSchema,
// Trust contract. Static for a given version; changes require
// a version bump because installed users have consented to the
// old contract. Default-empty so the minimal manifest doesn't
// need to spell out the absence of privileges.
capabilities: CapabilitiesSchema.default([]),
allowedHosts: AllowedHostsSchema.default([]),
storage: StorageSchema.default({}),
// Admin surface. Optional; plugins that don't expose any admin
// UI omit the key entirely. The runtime checks that any plugin
// declaring admin.pages or admin.widgets also serves an `admin`
// route — the schema can't enforce that here because route
// names are probed from src/plugin.ts, not the manifest.
admin: AdminSchema.optional(),
// Single-author form. Mutually exclusive with `authors`.
author: AuthorSchema.optional(),
// Multi-author form. Mutually exclusive with `author`. At least one
// entry is required when this field is used.
authors: z
.array(AuthorSchema)
.min(1, "authors[] must have at least one entry")
.max(32, "authors[] must have <= 32 entries (lexicon constraint)")
.meta({
title: "Authors (multiple)",
description:
"Multi-author form. Mutually exclusive with `author`. Use the singular `author` if there is only one.",
})
.optional(),
// Single-contact form. Mutually exclusive with `securityContacts`.
security: SecurityContactSchema.optional(),
// Multi-contact form. Mutually exclusive with `security`.
securityContacts: z
.array(SecurityContactSchema)
.min(1, "securityContacts[] must have at least one entry")
.max(8, "securityContacts[] must have <= 8 entries (lexicon constraint)")
.meta({
title: "Security contacts (multiple)",
description:
"Multi-contact form. Mutually exclusive with `security`. Use the singular `security` if there is only one.",
})
.optional(),
// Optional profile fields.
name: NameSchema.optional(),
description: DescriptionSchema.optional(),
keywords: KeywordsSchema.optional(),
// Long-form profile sections (description / installation / faq /
// changelog / security). Profile-level, like the fields above. File
// refs are read inline at load time relative to the manifest.
sections: SectionsSchema.optional(),
// Optional release fields.
repo: RepoSchema.optional(),
// Per-release block: environment constraints (`requires`) and media
// artifacts (icon / screenshot / banner). File refs are resolved
// relative to the manifest at publish time.
release: ReleaseSchema.optional(),
})
.strict()
.refine((v) => !(v.author !== undefined && v.authors !== undefined), {
message:
"manifest has both `author` and `authors`. Use one form: `author: { ... }` for a single author, or `authors: [...]` for multiple.",
path: ["authors"],
})
.refine((v) => !(v.security !== undefined && v.securityContacts !== undefined), {
message:
"manifest has both `security` and `securityContacts`. Use one form: `security: { ... }` for a single contact, or `securityContacts: [...]` for multiple.",
path: ["securityContacts"],
})
.refine((v) => v.author !== undefined || v.authors !== undefined, {
message: "manifest must specify either `author: { ... }` or `authors: [...]`",
path: ["author"],
})
.refine((v) => v.security !== undefined || v.securityContacts !== undefined, {
message: "manifest must specify either `security: { ... }` or `securityContacts: [...]`",
path: ["security"],
})
.refine(
(v) => {
// network:request without :unrestricted requires a non-empty
// allowedHosts. Without this guard, the lexicon's
// networkRequestConstraints rule fires at publish time and
// users see a confusing PDS error rather than a schema error.
const caps = new Set((v.capabilities ?? []).map((c) => normalizeCapability(c)));
if (caps.has("network:request") && !caps.has("network:request:unrestricted")) {
return (v.allowedHosts ?? []).length > 0;
}
return true;
},
{
message:
'capability "network:request" requires a non-empty `allowedHosts` list. Either add hosts, or upgrade to "network:request:unrestricted" if the plugin really needs to call any host.',
path: ["allowedHosts"],
},
)
.refine(
(v) => {
// network:request:unrestricted with allowedHosts is contradictory
// — the unrestricted capability says "any host", but the list
// implies "only these". The lexicon's rule is "allowedHosts
// MUST NOT appear when unrestricted"; same here.
const caps = new Set((v.capabilities ?? []).map((c) => normalizeCapability(c)));
if (caps.has("network:request:unrestricted")) {
return (v.allowedHosts ?? []).length === 0;
}
return true;
},
{
message:
'`allowedHosts` must be empty when "network:request:unrestricted" is declared (the unrestricted capability already grants any host).',
path: ["allowedHosts"],
},
)
.meta({
title: "EmDash plugin manifest",
description:
"Hand-authored manifest for publishing a plugin to the EmDash plugin registry. Lives next to the plugin's `package.json` as `emdash-plugin.jsonc`.",
});
/**
* Validated manifest shape. Note: this is the SHAPE AFTER the schema's
* `.refine()` rules have run, not the on-disk shape. The single-form
* convenience fields (`author`, `security`) are still present at this
* stage; normalisation to the array forms happens in `./translate.ts`.
*/
export type Manifest = z.infer<typeof ManifestSchema>;
/** A single author entry, normalised. */
export type ManifestAuthor = z.infer<typeof AuthorSchema>;
/** A single security contact entry, normalised. */
export type ManifestSecurityContact = z.infer<typeof SecurityContactSchema>;
/** A single media-artifact file reference (icon / screenshot / banner). */
export type ManifestArtifactFile = z.infer<typeof ArtifactFileSchema>;
/** A single section file reference (`{ file }`). */
export type ManifestSectionFile = z.infer<typeof SectionFileSchema>;
/** The long-form sections block, as authored (inline strings or file refs). */
export type ManifestSections = z.infer<typeof SectionsSchema>;
/** The release media-artifacts block. */
export type ManifestArtifacts = z.infer<typeof ArtifactsSchema>;
@@ -0,0 +1,361 @@
/**
* Translate a validated manifest into the existing publish-input shape.
*
* The single-author / single-security-contact convenience forms are
* normalised here: by the time this returns, the caller sees only the
* array shapes the lexicon uses.
*/
import { readFile } from "node:fs/promises";
import { isAbsolute, relative, resolve, sep } from "node:path";
import type { PluginCapability, PluginStorageConfig } from "@emdash-cms/plugin-types";
import type { ProfileBootstrap, ProfileInput } from "../publish/api.js";
import {
type Manifest,
type ManifestArtifacts,
type ManifestAuthor,
type ManifestSecurityContact,
type ManifestSections,
SECTION_KEYS,
type SectionKey,
sectionCapError,
} from "./schema.js";
/**
* Normalised "after the schema's single/multi convenience has been
* collapsed" view of a manifest. The CLI passes this to the publish
* pipeline rather than the raw `Manifest` so the rest of the code
* never has to think about `author` vs `authors`.
*/
/**
* Admin surface, mirroring the structure the runtime expects. Pulled
* out as a type alias so the bundle layer can pass it through to the
* bundled `manifest.json` without re-asserting the shape.
*/
export interface NormalisedAdmin {
pages: Array<{ path: string; label: string; icon?: string }>;
widgets: Array<{ id: string; title?: string; size?: "full" | "half" | "third" }>;
}
export interface NormalisedManifest {
// Identity. All three are guaranteed present in the normalised
// form: `slug` and `publisher` are required at authoring time,
// and `version` is resolved during normalisation from the manifest
// or `package.json#version` (with a mismatch / missing check).
slug: string;
version: string;
publisher: string;
// Profile.
license: string;
authors: ManifestAuthor[];
securityContacts: ManifestSecurityContact[];
name: string | undefined;
description: string | undefined;
keywords: string[] | undefined;
repo: string | undefined;
/**
* Long-form profile sections, resolved to inline strings. File refs have
* been read into their content and every value re-checked against the
* 20000-byte / 2000-grapheme cap. `undefined` when the manifest declared
* none. Populated by {@link resolveSections} during load (see
* `loadManifestBootstrap`); `normaliseManifest` leaves it undefined for
* the file-free path.
*/
sections?: Partial<Record<SectionKey, string>>;
/**
* Release-level environment constraints (`release.requires`). Map of
* `env:*`/DID keys to semver ranges. Release-level, not profile-level —
* passed to `publishRelease` separately, never via `manifestToProfileInput`.
*/
requires: Record<string, string> | undefined;
/**
* Release media artifacts (icon / screenshot / banner). File refs only —
* the publish command resolves, measures, and uploads them. `undefined`
* when the manifest declared none.
*/
artifacts: ManifestArtifacts | undefined;
// Trust contract (defaults applied by the schema; always present here).
capabilities: PluginCapability[];
allowedHosts: string[];
storage: PluginStorageConfig;
/**
* Admin surface. Always present in the normalised form (with
* empty arrays when the manifest didn't declare anything) so the
* bundle layer can pass it through without conditional handling.
*/
admin: NormalisedAdmin;
}
/**
* Thrown when the source manifest and the package's `package.json` carry
* different versions, or when neither carries one. Callers convert this
* into their own error code (BuildError, BundleError, ManifestError).
*/
export class VersionMismatchError extends Error {
override readonly name = "VersionMismatchError";
readonly code: "VERSION_MISMATCH" | "VERSION_MISSING";
readonly manifestVersion: string | undefined;
readonly packageVersion: string | undefined;
constructor(
code: "VERSION_MISMATCH" | "VERSION_MISSING",
message: string,
manifestVersion: string | undefined,
packageVersion: string | undefined,
) {
super(message);
this.code = code;
this.manifestVersion = manifestVersion;
this.packageVersion = packageVersion;
}
}
/**
* Thrown when a profile section can't be resolved: a `{ file }` ref that
* escapes the manifest directory, an unreadable file, or resolved content
* that exceeds the per-section cap. Callers convert this into their own
* command-level error (CliError).
*/
export class SectionError extends Error {
override readonly name = "SectionError";
readonly code: "SECTION_PATH_ESCAPE" | "SECTION_FILE_UNREADABLE" | "SECTION_TOO_LARGE";
/** The section key this error is about (`description`, `faq`, ...). */
readonly section: SectionKey;
constructor(
code: "SECTION_PATH_ESCAPE" | "SECTION_FILE_UNREADABLE" | "SECTION_TOO_LARGE",
message: string,
section: SectionKey,
) {
super(message);
this.code = code;
this.section = section;
}
}
/**
* Resolve a manifest's `sections` block into a map of inline strings.
*
* Each value is either an inline string (passed through, re-checked against
* the cap) or a `{ file }` ref (read relative to `manifestDir`, then capped).
* File refs are resolved here rather than at publish because sections are
* inlined into the profile record — there are no bytes to upload, only text to
* embed. Returns `undefined` when the manifest declared no sections.
*
* Throws {@link SectionError} on a path escape, an unreadable file, or content
* over the 20000-byte / 2000-grapheme cap, so `validate` / `publish` fails
* locally with a clear message instead of a 400 from the PDS.
*/
export async function resolveSections(
sections: ManifestSections | undefined,
manifestDir: string,
): Promise<Partial<Record<SectionKey, string>> | undefined> {
if (!sections) return undefined;
const resolved: Partial<Record<SectionKey, string>> = {};
for (const key of SECTION_KEYS) {
const value = sections[key];
if (value === undefined) continue;
const content =
typeof value === "string" ? value : await readSectionFile(manifestDir, value.file, key);
const capError = sectionCapError(content);
if (capError) {
throw new SectionError("SECTION_TOO_LARGE", `section "${key}": ${capError}.`, key);
}
resolved[key] = content;
}
return Object.keys(resolved).length > 0 ? resolved : undefined;
}
/**
* Read a section `{ file }` ref's content, refusing paths that escape the
* manifest directory (via `..` or an absolute path). The manifest is
* publisher-authored, but a traversal would let `publish` read arbitrary
* files off the machine running the CLI and embed them in the published
* profile, so the boundary is enforced — same rule as media artifacts.
*/
async function readSectionFile(
manifestDir: string,
file: string,
section: SectionKey,
): Promise<string> {
const absolute = resolve(manifestDir, file);
const rel = relative(manifestDir, absolute);
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(file)) {
throw new SectionError(
"SECTION_PATH_ESCAPE",
`section "${section}" file path ${file} resolves outside the manifest directory.`,
section,
);
}
try {
return await readFile(absolute, "utf8");
} catch (error) {
throw new SectionError(
"SECTION_FILE_UNREADABLE",
`section "${section}" could not read file ${file}: ${error instanceof Error ? error.message : String(error)}`,
section,
);
}
}
/**
* Reconcile the manifest's `version` with the package's `version`.
*
* - Both present and equal → returns that string.
* - Both present and different → throws `VERSION_MISMATCH`.
* - Only one present → returns it.
* - Neither present → throws `VERSION_MISSING`.
*
* Surrounding whitespace on either input is rejected with a dedicated
* error so a visually-identical-but-not-equal pair like `"1.0.0 "`
* vs `"1.0.0"` doesn't print a confusing mismatch message.
*/
export function resolvePluginVersion(
manifestVersion: string | undefined,
packageVersion: string | undefined,
): string {
if (manifestVersion !== undefined && manifestVersion.trim() !== manifestVersion) {
throw new VersionMismatchError(
"VERSION_MISMATCH",
`Plugin version in emdash-plugin.jsonc has leading or trailing whitespace (${JSON.stringify(manifestVersion)}). Trim it.`,
manifestVersion,
packageVersion,
);
}
if (packageVersion !== undefined && packageVersion.trim() !== packageVersion) {
throw new VersionMismatchError(
"VERSION_MISMATCH",
`Plugin version in package.json has leading or trailing whitespace (${JSON.stringify(packageVersion)}). Trim it.`,
manifestVersion,
packageVersion,
);
}
if (manifestVersion !== undefined && packageVersion !== undefined) {
if (manifestVersion !== packageVersion) {
throw new VersionMismatchError(
"VERSION_MISMATCH",
`Plugin version disagrees between emdash-plugin.jsonc (${manifestVersion}) and package.json (${packageVersion}). Remove "version" from emdash-plugin.jsonc to let package.json drive it, or align both values.`,
manifestVersion,
packageVersion,
);
}
return manifestVersion;
}
if (manifestVersion !== undefined) return manifestVersion;
if (packageVersion !== undefined) return packageVersion;
throw new VersionMismatchError(
"VERSION_MISSING",
'Plugin version not set. Add "version" to package.json (npm-distributed plugins) or to emdash-plugin.jsonc (registry-only plugins).',
manifestVersion,
packageVersion,
);
}
/**
* Collapse the convenience forms (`author`, `security`) into the array
* forms (`authors`, `securityContacts`), and reconcile the manifest's
* optional `version` against the package's `version` so callers see a
* single resolved string.
*
* The manifest schema's `.refine()` rules already guarantee that exactly
* one of each name/contact pair is set, so the runtime checks here are
* defensive — a caller that bypassed validation would still produce a
* coherent result.
*
* Pass `packageVersion: undefined` for registry-only plugins with no
* `package.json` — in that case the manifest's `version` is used
* directly (and is required, by the same `resolvePluginVersion` rules).
*/
export function normaliseManifest(manifest: Manifest, packageVersion?: string): NormalisedManifest {
const authors = manifest.authors ?? (manifest.author ? [manifest.author] : []);
const securityContacts =
manifest.securityContacts ?? (manifest.security ? [manifest.security] : []);
const version = resolvePluginVersion(manifest.version, packageVersion);
return {
slug: manifest.slug,
version,
publisher: manifest.publisher,
license: manifest.license,
authors,
securityContacts,
name: manifest.name,
description: manifest.description,
keywords: manifest.keywords,
repo: manifest.repo,
requires: manifest.release?.requires,
artifacts: manifest.release?.artifacts,
// Schema validation already gates capability strings to the
// current vocabulary via a runtime check, so by the time we get
// here the strings are guaranteed members of PluginCapability.
// Zod's inferred type is `string[]` (it can't see the runtime
// narrowing), and the cast bridges that gap.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- schema-enforced narrowing
capabilities: manifest.capabilities as PluginCapability[],
allowedHosts: manifest.allowedHosts,
// Same story for storage: Zod returns Record<string, {...}>,
// PluginStorageConfig is the same shape with a tighter key
// constraint.
storage: manifest.storage,
admin: {
pages: manifest.admin?.pages ?? [],
widgets: manifest.admin?.widgets ?? [],
},
};
}
/**
* Convert a normalised manifest into the structured `ProfileInput` that
* `publishRelease` consumes. Carries the full lexicon profile block:
* multi-author, multi-security, name, description, keywords. `repo` is a
* release-level field and is passed separately (see `NormalisedManifest.repo`).
*/
export function manifestToProfileInput(manifest: NormalisedManifest): ProfileInput {
const input: ProfileInput = {
license: manifest.license,
authors: manifest.authors.map((a) => ({
name: a.name,
...(a.url !== undefined ? { url: a.url } : {}),
...(a.email !== undefined ? { email: a.email } : {}),
})),
security: manifest.securityContacts.map((c) => ({
...(c.url !== undefined ? { url: c.url } : {}),
...(c.email !== undefined ? { email: c.email } : {}),
})),
};
if (manifest.name !== undefined) input.name = manifest.name;
if (manifest.description !== undefined) input.description = manifest.description;
if (manifest.keywords !== undefined) input.keywords = manifest.keywords;
if (manifest.sections !== undefined && Object.keys(manifest.sections).length > 0) {
input.sections = manifest.sections;
}
return input;
}
/**
* Convert a normalised manifest into the deprecated flat `ProfileBootstrap`
* shape. For multi-author manifests, the first author wins (the flat shape
* models only one author and one security contact).
*
* @deprecated Use {@link manifestToProfileInput}. Retained for callers still
* on the flat publish path.
*/
export function manifestToProfileBootstrap(manifest: NormalisedManifest): ProfileBootstrap {
const author = manifest.authors[0];
const security = manifest.securityContacts[0];
const profile: ProfileBootstrap = { license: manifest.license };
if (author?.name !== undefined) profile.authorName = author.name;
if (author?.url !== undefined) profile.authorUrl = author.url;
if (author?.email !== undefined) profile.authorEmail = author.email;
if (security?.email !== undefined) profile.securityEmail = security.email;
if (security?.url !== undefined) profile.securityUrl = security.url;
return profile;
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Multibase-encoded multihash helpers.
*
* The registry RFC requires artifact checksums in multibase-multihash format:
*
* <multibase-prefix><varint hash-code><varint length><digest bytes>
*
* For sha2-256 (the only hash function clients MUST support), `code = 0x12`,
* `length = 0x20` (32 bytes), both fitting in a single varint byte. We encode
* the result with the base32 multibase prefix `b` (lowercase, no padding),
* which is the recommended encoding per the RFC and the most CID-friendly
* choice.
*
* Concretely, a sha2-256 multihash is `0x12 0x20 || digest32` (34 bytes
* total) and the multibase output is `b` + base32(those 34 bytes).
*/
import { toBase32 } from "@atcute/multibase";
import { sha256 } from "@oslojs/crypto/sha2";
/** multihash code for sha2-256 (single-byte varint). */
const SHA256_CODE = 0x12;
/** sha2-256 digest length in bytes (single-byte varint). */
const SHA256_LENGTH = 0x20;
/**
* Compute the multibase-multihash sha2-256 checksum of the given bytes.
*
* @returns the base32-multibase string, e.g. `bciq...`. The output is the
* single-character multibase prefix `b` followed by base32 encoding of
* 34 bytes (2-byte multihash header + 32-byte digest), totalling 56
* characters.
*/
export function sha256Multihash(bytes: Uint8Array): string {
const digest = sha256(bytes);
if (digest.length !== SHA256_LENGTH) {
throw new Error(`expected sha256 digest to be ${SHA256_LENGTH} bytes, got ${digest.length}`);
}
const multihash = new Uint8Array(2 + digest.length);
multihash[0] = SHA256_CODE;
multihash[1] = SHA256_LENGTH;
multihash.set(digest, 2);
// Multibase prefix `b` indicates base32 lowercase, no padding.
return `b${toBase32(multihash)}`;
}
+708
View File
@@ -0,0 +1,708 @@
/**
* OAuth helpers for the registry CLI.
*
* Implements the interactive atproto OAuth dance:
*
* 1. The CLI binds a loopback HTTP server on a random ephemeral port.
* 2. The CLI calls `OAuthClient.authorize(...)`, gets an authorization URL,
* and asks the user to open it in a browser (best-effort auto-open).
* 3. The user completes the flow; their browser redirects to
* `http://127.0.0.1:<port>/callback?code=...&state=...`.
* 4. The local server hands the query string to `OAuthClient.callback(...)`,
* which exchanges the code for a session, and the server closes.
* 5. The CLI returns the resulting `OAuthSession` to the caller.
*
* The OAuth library handles DPoP, PAR, PKCE, and refresh under the hood. We
* persist its `StoredSession` blobs to disk via a small filesystem-backed
* `Store` so subsequent CLI invocations can resume the session without a
* fresh login.
*/
import { mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { dirname, join } from "node:path";
import {
CompositeDidDocumentResolver,
CompositeHandleResolver,
DohJsonHandleResolver,
LocalActorResolver,
PlcDidDocumentResolver,
WebDidDocumentResolver,
WellKnownHandleResolver,
} from "@atcute/identity-resolver";
import type { ActorIdentifier, Did } from "@atcute/lexicons";
import { isDid, isHandle } from "@atcute/lexicons/syntax";
import {
type LoopbackClientMetadata,
type OAuthSession,
OAuthClient,
OAuthResponseError,
type Store,
type StoredSession,
type StoredState,
} from "@atcute/oauth-node-client";
import { QUERY_NSIDS, RECORD_NSIDS } from "@emdash-cms/registry-lexicons";
import { DEFAULT_OAUTH_DIR } from "./config.js";
/**
* Default OAuth scope for the registry CLI. Granular per the atproto OAuth
* permission spec, derived from the lexicon set in `@emdash-cms/registry-lexicons`:
*
* - `atproto`: base requirement (DID-bound DPoP session, identity).
* - `repo:<nsid>` for every record-shaped lexicon: write profile, release,
* publisher profile, and verification records via `applyWrites`. Repo
* reads of public records don't require a scope, and embedded objects
* (`releaseExtension`) ride inside their parent record.
* - `rpc:<nsid>?aud=*` for every query-shaped lexicon: cover the
* aggregator XRPC methods even though the publish CLI doesn't call them
* yet — granting them at login means future tooling that resumes the
* stored session can call the aggregator without forcing a re-login.
* `aud=*` because the aggregator's service DID isn't pinned today.
*
* `transition:generic` is intentionally not included. PDSes accept granular
* scopes at PAR even when their `scopes_supported` metadata still lists only
* the transitional shims, so requesting only what we need keeps the consent
* screen honest.
*/
const DEFAULT_CLI_SCOPE = [
"atproto",
...RECORD_NSIDS.map((nsid) => `repo:${nsid}`),
...QUERY_NSIDS.map((nsid) => `rpc:${nsid}?aud=*`),
].join(" ");
/**
* Legacy fallback scope used when the AS returns `invalid_scope` for the
* granular request. `transition:generic` predates the granular permission
* spec and every atproto OAuth server has supported it since OAuth shipped,
* so it's the safe re-try shape. The publish flow doesn't get any narrower
* permissions out of this path -- it's purely a compatibility shim for
* publishers on un-upgraded PDSes.
*/
const LEGACY_FALLBACK_SCOPE = "atproto transition:generic";
// ──────────────────────────────────────────────────────────────────────────
// Filesystem-backed Store<K, V>
// ──────────────────────────────────────────────────────────────────────────
interface FileStoreEnvelope<V> {
version: number;
entries: Record<string, V>;
}
const FILE_STORE_VERSION = 1;
/**
* Generic JSON-file-backed store. Keys are stringified for filenames; values
* are JSON-serialised in a single envelope file with a version field for
* forward compatibility.
*
* Atomic writes: a temp file is created with mode 0600 and renamed over the
* target. POSIX rename is atomic, so a crash mid-write leaves the previous
* file intact.
*/
class FileStore<V> implements Store<string, V> {
readonly #path: string;
readonly #cache = new Map<string, V>();
#loaded = false;
constructor(path: string) {
this.#path = path;
}
async get(key: string): Promise<V | undefined> {
await this.#ensureLoaded();
return this.#cache.get(key);
}
async set(key: string, value: V): Promise<void> {
await this.#ensureLoaded();
this.#cache.set(key, value);
await this.#flush();
}
async delete(key: string): Promise<void> {
await this.#ensureLoaded();
this.#cache.delete(key);
await this.#flush();
}
async clear(): Promise<void> {
await this.#ensureLoaded();
this.#cache.clear();
await this.#flush();
}
async #ensureLoaded(): Promise<void> {
if (this.#loaded) return;
try {
const raw = await readFile(this.#path, "utf8");
const parsed: unknown = JSON.parse(raw);
if (
parsed &&
typeof parsed === "object" &&
"entries" in parsed &&
parsed.entries &&
typeof parsed.entries === "object"
) {
// `V` is opaque to the FileStore -- the OAuth library is the
// only writer and reader, and it round-trips its own typed
// values through us. We trust whatever's on disk to match the
// type the same OAuth client wrote. Re-validating here would
// require duplicating the OAuth library's StoredSession /
// StoredState schemas.
// eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion
for (const [k, v] of Object.entries(parsed.entries) as Array<[string, V]>) {
this.#cache.set(k, v);
}
}
} catch (error) {
// Missing file is fine; anything else (corruption, permission) we let
// surface — the user's CLI will then exit non-zero with the error.
if (!isErrnoException(error) || error.code !== "ENOENT") {
throw error;
}
}
this.#loaded = true;
}
async #flush(): Promise<void> {
const dir = dirname(this.#path);
await mkdir(dir, { recursive: true, mode: 0o700 });
const envelope: FileStoreEnvelope<V> = {
version: FILE_STORE_VERSION,
entries: Object.fromEntries(this.#cache),
};
const body = `${JSON.stringify(envelope, null, 2)}\n`;
const tmp = `${this.#path}.tmp`;
try {
// `flush: true` (Node 21.1+) fsyncs the file content before close, so
// a power loss between the rename and a crash can't surface an empty
// inode pointing at unwritten data. Atomic rename alone is torn-write
// safe but not durable.
await writeFile(tmp, body, { mode: 0o600, flush: true });
await rename(tmp, this.#path);
// On Linux, fsync the directory after the rename so the rename
// itself is durable across power loss (POSIX file fsync persists
// the inode but not the directory entry). On macOS the prior
// file fsync already covers this via the journal. On Windows
// `open(dir, "r")` rejects with EISDIR/EACCES; we swallow the
// error so the write still succeeds. Net effect: durable rename
// on Linux + journaled FS; benign no-op everywhere else.
await fsyncDir(dir).catch(() => {});
} catch (error) {
// Best-effort cleanup of the temp file if rename failed mid-write.
await unlink(tmp).catch(() => {});
throw error;
}
}
}
/**
* fsync a directory so that a rename or unlink inside it is durable across
* power loss. Node doesn't expose a `fs.fsyncDir` shortcut; the trick is to
* `open` the directory (read-only) and call `fsync` on the FileHandle.
* Throws on platforms that reject opening a directory; callers should
* `.catch(() => {})` since durability is best-effort.
*/
async function fsyncDir(path: string): Promise<void> {
const handle = await open(path, "r");
try {
await handle.sync();
} finally {
await handle.close();
}
}
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
return (
error instanceof Error &&
"code" in error &&
typeof (error as { code?: unknown }).code === "string"
);
}
// ──────────────────────────────────────────────────────────────────────────
// OAuth client construction
// ──────────────────────────────────────────────────────────────────────────
export interface OAuthClientFactoryOptions {
/**
* Directory for filesystem-backed OAuth state (sessions, in-flight states).
* Defaults to `~/.emdash/oauth/`.
*/
stateDir?: string;
/**
* Loopback redirect URL the CLI will receive the callback on. The host
* portion MUST be the IP literal `127.0.0.1` (RFC 8252 §8.3); `localhost`
* is rejected by the atcute OAuth library.
*/
redirectUri: `http://127.0.0.1:${number}/callback`;
/**
* Scopes to request. Defaults to {@link DEFAULT_CLI_SCOPE}: `atproto` plus
* `repo:<nsid>` for every record-shaped lexicon in
* `@emdash-cms/registry-lexicons` and `rpc:<nsid>?aud=*` for every
* aggregator query.
*/
scope?: string;
}
/**
* Build a `LocalActorResolver` for atproto identity lookups (handle <-> DID,
* DID document, PDS endpoint). Shared by the OAuth client and post-login
* profile resolution so they agree on handle/DID round-trip rules.
*/
export function createActorResolver(): LocalActorResolver {
return new LocalActorResolver({
handleResolver: new CompositeHandleResolver({
methods: {
dns: new DohJsonHandleResolver({
dohUrl: "https://cloudflare-dns.com/dns-query",
}),
http: new WellKnownHandleResolver(),
},
}),
didDocumentResolver: new CompositeDidDocumentResolver({
methods: {
plc: new PlcDidDocumentResolver(),
web: new WebDidDocumentResolver(),
},
}),
});
}
/**
* Build an `OAuthClient` configured as a loopback public client with PKCE.
*
* Per RFC 8252, loopback public clients don't need a published client metadata
* document — the PDS derives metadata from the `client_id` URL parameters.
* This keeps the CLI self-contained: no JWKS endpoint, no static metadata
* file, no key management.
*/
export function createCliOAuthClient(options: OAuthClientFactoryOptions): OAuthClient {
const stateDir = options.stateDir ?? DEFAULT_OAUTH_DIR;
const sessions = new FileStore<StoredSession>(join(stateDir, "sessions.json"));
const states = new FileStore<StoredState>(join(stateDir, "states.json"));
const actorResolver = createActorResolver();
// Loopback public client per RFC 8252: no client_id, no JWKS, no
// confidential auth. The PDS derives metadata from the client_id URL
// parameters during the authorize flow. `redirect_uris` MUST use
// `127.0.0.1` (not `localhost`) per RFC 8252 §8.3 and the atcute
// loopbackRedirectUriSchema.
const metadata: LoopbackClientMetadata = {
redirect_uris: [options.redirectUri],
scope: options.scope ?? DEFAULT_CLI_SCOPE,
};
return new OAuthClient({
metadata,
stores: {
sessions: sessions,
states: states,
},
actorResolver,
});
}
// ──────────────────────────────────────────────────────────────────────────
// Loopback callback server
// ──────────────────────────────────────────────────────────────────────────
function renderCallbackPage(title: string, message: string): string {
return `<!doctype html><meta charset="utf-8"><title>${escapeHtml(title)}</title>
<style>body{font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem;color:#222}h1{font-size:1.25rem}p{color:#666}</style>
<h1>${escapeHtml(title)}</h1><p>${escapeHtml(message)}</p><p><small>You can close this tab.</small></p>`;
}
function escapeHtml(input: string): string {
return input
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
.replaceAll("/", "&#x2F;");
}
/**
* Outcome the caller passes back into the loopback server to decide what to
* render in the user's browser. Only after the caller (atcute) has accepted
* the callback do we render success; if the callback didn't validate, we
* render an error so the user knows the login failed.
*/
export type CallbackOutcome =
| { ok: true; title?: string; message?: string }
| { ok: false; title?: string; message?: string };
export interface BindLoopbackServerResult {
redirectUri: `http://127.0.0.1:${number}/callback`;
/**
* Resolves with the OAuth callback URL search params once the AS redirects
* the user's browser to `/callback`. Rejects on timeout.
*
* The HTTP response to the browser is held open until the caller invokes
* `respond(...)` -- this lets the caller render success only after the
* params have been validated by atcute, and an error message if they
* haven't.
*/
awaitCallback(): Promise<URLSearchParams>;
/**
* Send the rendered success / error page to the user's browser. Idempotent;
* subsequent calls are no-ops. The CLI is expected to call this exactly
* once per flow.
*/
respond(outcome: CallbackOutcome): void;
/** Stop the server. Idempotent. */
close(): Promise<void>;
}
/**
* Bind a small HTTP server on `127.0.0.1` at an OS-chosen ephemeral port and
* return a callback path the OAuth flow can redirect to.
*
* The server only responds to GET `/callback`. Any other request gets a 405
* or 400.
*
* Importantly, the server holds the response open until the caller invokes
* `respond(...)` -- so the user's browser shows "Login complete" only AFTER
* atcute has validated the callback params, not before.
*
* @param timeoutMs How long to wait for the callback before rejecting.
* Defaults to 5 minutes, matching the typical AS code TTL.
*/
export async function bindLoopbackServer(
timeoutMs = 5 * 60 * 1000,
): Promise<BindLoopbackServerResult> {
let resolveCallback: ((params: URLSearchParams) => void) | undefined;
let rejectCallback: ((error: Error) => void) | undefined;
let settled = false;
const callbackPromise = new Promise<URLSearchParams>((resolve, reject) => {
resolveCallback = (params) => {
if (settled) return;
settled = true;
resolve(params);
};
rejectCallback = (error) => {
if (settled) return;
settled = true;
reject(error);
};
});
// Held open until `respond()` is called. The first /callback request
// captures `pendingResponse`; subsequent ones get a "you've already
// completed login" message so a refresh / stray tab can't silently re-fire.
let pendingResponse: ServerResponse | undefined;
let responded = false;
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
if (req.method !== "GET") {
res.statusCode = 405;
res.setHeader("allow", "GET");
res.end();
return;
}
if (url.pathname !== "/callback") {
res.statusCode = 404;
res.end();
return;
}
// Reject /callback hits with no `state` param. atcute will reject these
// too, but a stray tab firing at the loopback shouldn't claim the
// pending promise -- so handle it in-band.
if (!url.searchParams.has("state")) {
res.statusCode = 400;
res.setHeader("content-type", "text/html; charset=utf-8");
res.end(
renderCallbackPage(
"EmDash plugin login",
"Waiting for the actual login callback. (This request had no state parameter.)",
),
);
return;
}
// Already-completed: a second /callback hit (browser refresh, stray
// tab) gets a generic "you're done" message and doesn't re-trigger.
if (settled) {
res.statusCode = 200;
res.setHeader("content-type", "text/html; charset=utf-8");
res.end(
renderCallbackPage(
"EmDash plugin login",
"Login already completed. You can close this tab.",
),
);
return;
}
// First valid callback: hold the response open until the CLI tells us
// what to render. The CLI does this once atcute has consumed the params
// and either accepted them (render success) or rejected (render error).
pendingResponse = res;
resolveCallback?.(url.searchParams);
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.address();
if (!address || typeof address === "string") {
server.close();
throw new Error("could not determine loopback server address");
}
const port = address.port;
const redirectUri = `http://127.0.0.1:${port}/callback` as const;
const timeout = setTimeout(() => {
rejectCallback?.(new Error(`OAuth callback timed out after ${Math.round(timeoutMs / 1000)}s`));
}, timeoutMs);
timeout.unref();
const respond = (outcome: CallbackOutcome): void => {
if (responded) return;
responded = true;
const res = pendingResponse;
if (!res) return;
res.statusCode = outcome.ok ? 200 : 400;
res.setHeader("content-type", "text/html; charset=utf-8");
res.end(
renderCallbackPage(
outcome.title ?? (outcome.ok ? "EmDash plugin login" : "Login failed"),
outcome.message ??
(outcome.ok
? "Login complete. Returning you to the CLI."
: "The login callback could not be validated. Check the CLI for details."),
),
);
};
const close = async (): Promise<void> => {
clearTimeout(timeout);
// If we never responded (timeout, error before respond), close the
// dangling response so the browser doesn't hang.
if (!responded && pendingResponse) {
responded = true;
try {
pendingResponse.end();
} catch {
// the socket may already be gone; safe to ignore
}
}
await new Promise<void>((resolve) => server.close(() => resolve()));
};
return {
redirectUri,
awaitCallback: () => callbackPromise,
respond,
close,
};
}
// ──────────────────────────────────────────────────────────────────────────
// Browser open (best-effort)
// ──────────────────────────────────────────────────────────────────────────
/**
* Best-effort attempt to open `url` in the user's default browser. Failure is
* non-fatal: the CLI prints the URL too, so a headless or sandboxed user can
* complete the flow manually. We do NOT await the spawned process.
*/
export function tryOpenBrowser(url: string): void {
void (async () => {
try {
const { execFile } = await import("node:child_process");
if (process.platform === "darwin") {
execFile("open", [url]);
} else if (process.platform === "win32") {
execFile("cmd", ["/c", "start", "", url]);
} else {
execFile("xdg-open", [url]);
}
} catch {
// swallowed by design
}
})();
}
// ──────────────────────────────────────────────────────────────────────────
// Top-level: run an interactive login
// ──────────────────────────────────────────────────────────────────────────
/**
* Validate and narrow a user-supplied identifier (handle or DID) to the
* `ActorIdentifier` type the OAuth library expects. Throws a CLI-shaped error
* message if neither shape matches.
*/
function parseActorIdentifier(input: string): ActorIdentifier {
const trimmed = input.trim();
if (isDid(trimmed) || isHandle(trimmed)) {
return trimmed;
}
throw new Error(
`"${input}" is not a valid handle or DID. Expected a handle like "alice.example.com" or a DID like "did:plc:abc123..."`,
);
}
export interface RunInteractiveLoginOptions {
/** Handle or DID the user wants to authenticate as. */
identifier: string;
/** OAuth state directory. Defaults to `~/.emdash/oauth/`. */
stateDir?: string;
/** Override the loopback callback timeout. */
timeoutMs?: number;
/** Hook for printing the verification URL when the browser-open fails. */
onUrl?: (url: URL) => void;
/**
* Hook fired when the AS rejected the granular scope request and the
* flow is retrying with the legacy `transition:generic` fallback. The
* CLI uses this to print a notice so the user knows their PDS is
* granting broader permissions than the spec-compliant path would.
*/
onLegacyScopeFallback?: () => void;
}
export interface RunInteractiveLoginResult {
session: OAuthSession;
did: Did;
}
/**
* Build an OAuth client, call `authorize`, and on `invalid_scope` retry once
* with the legacy `transition:generic` shim. Returns whichever client actually
* pushed an authorization request, so the caller hands the same client to
* `callback()` (the second client owns the persisted state).
*
* `invalid_scope` is the well-defined RFC 6749 §5.2 error the AS returns when
* a requested scope isn't supported. The retry path doesn't fire on any other
* OAuth error -- a `bad_request`, `server_error`, etc. all bubble up so the
* login command can render them via its `OAuthResponseError` handler.
*/
async function authorizeWithLegacyFallback(input: {
stateDir: string | undefined;
redirectUri: `http://127.0.0.1:${number}/callback`;
identifier: ActorIdentifier;
onLegacyScopeFallback: (() => void) | undefined;
}): Promise<{ client: OAuthClient; url: URL }> {
const granular = createCliOAuthClient({
stateDir: input.stateDir,
redirectUri: input.redirectUri,
});
try {
const { url } = await granular.authorize({
target: { type: "account", identifier: input.identifier },
});
return { client: granular, url };
} catch (error) {
if (!(error instanceof OAuthResponseError) || error.error !== "invalid_scope") {
throw error;
}
input.onLegacyScopeFallback?.();
const legacy = createCliOAuthClient({
stateDir: input.stateDir,
redirectUri: input.redirectUri,
scope: LEGACY_FALLBACK_SCOPE,
});
const { url } = await legacy.authorize({
target: { type: "account", identifier: input.identifier },
});
return { client: legacy, url };
}
}
/**
* Run a full interactive OAuth login: build the client, bind the loopback
* server, open the browser, await the callback, exchange the code, and return
* the session.
*
* On success, the OAuth library has already persisted the session to the
* filesystem store, so subsequent CLI invocations can call
* `resumeSession(did)` and skip the interactive flow.
*/
export async function runInteractiveLogin(
options: RunInteractiveLoginOptions,
): Promise<RunInteractiveLoginResult> {
const server = await bindLoopbackServer(options.timeoutMs);
try {
const identifier = parseActorIdentifier(options.identifier);
const { client, url } = await authorizeWithLegacyFallback({
stateDir: options.stateDir,
redirectUri: server.redirectUri,
identifier,
onLegacyScopeFallback: options.onLegacyScopeFallback,
});
options.onUrl?.(url);
tryOpenBrowser(url.toString());
const params = await server.awaitCallback();
try {
const result = await client.callback(params);
// Atcute has accepted the callback. Only NOW render the success
// page in the user's browser -- so a stray /callback hit with
// invalid state can't trick the user into thinking they're logged
// in when they aren't.
server.respond({ ok: true });
return { session: result.session, did: result.session.sub };
} catch (error) {
// atcute rejected the callback (state mismatch, expired code, etc).
// Render an error page in the browser before surfacing the failure.
const message = error instanceof Error ? error.message : "Login could not be validated.";
server.respond({ ok: false, message });
throw error;
}
} finally {
await server.close();
}
}
/**
* Resume a previously-stored session by DID, refreshing tokens if needed.
* Throws if no session exists for the DID.
*
* The redirect URI is irrelevant for resume (it's only used during authorize),
* but the OAuth client constructor requires one matching the stored metadata.
* We pass a placeholder; the OAuth library never tries to bind it.
*/
export async function resumeSession(
did: Did,
options: { stateDir?: string } = {},
): Promise<OAuthSession> {
const client = createCliOAuthClient({
stateDir: options.stateDir,
redirectUri: "http://127.0.0.1:0/callback",
});
return client.restore(did);
}
/**
* Revoke a session and remove its stored state. Best-effort: a network failure
* during revocation is logged but does not prevent local cleanup, since the
* user's intent is "stop using this session on this machine".
*/
export async function revokeSession(did: Did, options: { stateDir?: string } = {}): Promise<void> {
const client = createCliOAuthClient({
stateDir: options.stateDir,
redirectUri: "http://127.0.0.1:0/callback",
});
try {
await client.revoke(did);
} catch {
// Local-cleanup-only fallback: drop the session entry directly so
// `restore` won't accidentally reuse a server-side-revoked session.
const sessions = new FileStore<StoredSession>(
join(options.stateDir ?? DEFAULT_OAUTH_DIR, "sessions.json"),
);
await sessions.delete(did);
}
}
+90
View File
@@ -0,0 +1,90 @@
/**
* Resolves the publisher's atproto profile (display name, handle, PDS URL)
* from a freshly-authenticated `OAuthSession`.
*
* Sources, in order of authority:
*
* 1. PDS URL: `session.getTokenInfo().aud`. The OAuth `aud` claim is the
* resource server URL the session is bound to -- exactly the PDS the
* session can actually talk to. Always populated for authenticated
* sessions; never empty.
* 2. Handle: resolved from the DID document via `LocalActorResolver`.
* `alsoKnownAs` is read off the DID doc and the handle is verified to
* round-trip back to the same DID before it's accepted. No PDS XRPC
* involved -- this works regardless of how the PDS handles OAuth/DPoP
* tokens, and doesn't need an `rpc:` scope. Falls back to the DID on
* failure; the caller detects that via `isHandle()`.
* 3. Display name: `app.bsky.actor.profile` (rkey `self`) via
* `getRecord` -- a public repo read that doesn't require auth. Absent
* profile records are not an error.
*
* The PDS URL is no longer best-effort: a session without a usable PDS
* is unrecoverable, so we throw rather than persist an empty string and
* lock the user out of subsequent commands.
*/
import type { OAuthSession } from "@atcute/oauth-node-client";
import { createActorResolver } from "./oauth.js";
export interface AtprotoProfile {
handle: string;
displayName: string | null;
pds: string;
}
export async function resolveAtprotoProfile(session: OAuthSession): Promise<AtprotoProfile> {
const did = session.sub;
// PDS URL: read directly from the OAuth token's `aud` claim. This is
// the URL atcute itself uses for every authenticated request from the
// session, so it's guaranteed populated. The previous implementation
// tried `getSession.pdsUrl`, which doesn't exist in the Bluesky lexicon
// -- the field is always undefined, leaving `pds` empty and corrupting
// the credentials store on the next read.
const tokenInfo = await session.getTokenInfo();
const pds = tokenInfo.aud;
if (typeof pds !== "string" || pds.length === 0) {
// Defensive: should be impossible per atcute's session model, but if
// it ever isn't, fail loudly here rather than persisting "" and
// locking the user out.
throw new Error(
"OAuth session has no `aud` (PDS URL); cannot resolve publisher profile. This is a bug -- please report it.",
);
}
let handle: string = did;
let displayName: string | null = null;
try {
const resolved = await createActorResolver().resolve(did);
// `LocalActorResolver` returns `'handle.invalid'` when the
// alsoKnownAs handle doesn't round-trip back to this DID. Treat
// that as "no handle" and fall back to the DID.
if (resolved.handle && resolved.handle !== "handle.invalid") {
handle = resolved.handle;
}
} catch {
// best-effort; fall through to DID as handle
}
try {
const res = await session.handle(
`/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.bsky.actor.profile&rkey=self`,
);
if (res.ok) {
displayName = pickDisplayName(await res.json());
}
} catch {
// optional record; absence is fine
}
return { handle, displayName, pds };
}
function pickDisplayName(input: unknown): string | null {
if (!input || typeof input !== "object") return null;
if (!("value" in input) || !input.value || typeof input.value !== "object") return null;
if (!("displayName" in input.value)) return null;
return typeof input.value.displayName === "string" ? input.value.displayName : null;
}
+884
View File
@@ -0,0 +1,884 @@
/**
* Programmatic publish API.
*
* Pure-ish core of the publish pipeline: given an already-fetched tarball
* checksum, an extracted manifest, an authenticated `PublishingClient`, and
* the URL the bytes are hosted at, this writes the profile (if missing) and
* release records to the publisher's atproto repo.
*
* Splits cleanly from the CLI command so tests can run it against a mock
* `PublishingClient` without going through OAuth, the filesystem credentials
* store, or an HTTP fetch for the tarball.
*
* Atomicity
* ---------
*
* Profile bootstrap + release create happen in a single atproto
* `applyWrites` commit, so a network blip mid-publish can't leave a profile
* with no releases (or vice versa). FAIR specifies version-record
* immutability; we refuse to overwrite an existing release at
* `<slug>:<version>` unless `allowOverwrite: true` is set.
*
* Validation
* ----------
*
* Slug (derived from `manifest.id`) and version are validated against the
* registry-lexicon constraints before any network round-trip, so the user
* gets a clear `PublishError` with the offending value rather than a generic
* `InvalidRequest` from the PDS. Profile-bootstrap fields (license, security
* contact) are also validated up-front for the same reason.
*
* Failure modes:
*
* - `DEPRECATED_CAPABILITY`: the manifest declares one of the deprecated
* capability names. Bundle warns; publish refuses.
* - `INVALID_SLUG` / `INVALID_VERSION`: the derived slug or the manifest
* version doesn't match the lexicon constraints.
* - `PROFILE_BOOTSTRAP_MISSING_FIELD`: first publish without the required
* `license` and `securityEmail`/`securityUrl`.
* - `RELEASE_ALREADY_PUBLISHED`: the release record at `<slug>:<version>`
* already exists in the repo. Pass `allowOverwrite: true` to opt in to
* overwriting (aggregators may flag the change as a takedown).
*/
import { ClientResponseError } from "@atcute/client";
import type { Nsid } from "@atcute/lexicons";
import { safeParse } from "@atcute/lexicons/validations";
import {
capabilitiesToDeclaredAccess,
deriveSlugFromId,
isDeprecatedCapability,
isPluginSlug,
isPluginVersion,
normalizeCapability,
type PluginManifest,
} from "@emdash-cms/plugin-types";
import type { Did, PublishingClient } from "@emdash-cms/registry-client";
import {
NSID,
PackageProfile,
PackageRelease,
PackageReleaseExtension,
} from "@emdash-cms/registry-lexicons";
// ──────────────────────────────────────────────────────────────────────────
// Public types
// ──────────────────────────────────────────────────────────────────────────
export type PublishErrorCode =
| "DEPRECATED_CAPABILITY"
| "INVALID_SLUG"
| "INVALID_VERSION"
| "INVALID_MANIFEST"
| "LEXICON_VALIDATION_FAILED"
| "PROFILE_BOOTSTRAP_MISSING_FIELD"
| "RELEASE_ALREADY_PUBLISHED";
export class PublishError extends Error {
readonly code: PublishErrorCode;
/** Optional structured detail for callers that want to render specifics. */
readonly detail: Record<string, unknown> | undefined;
constructor(code: PublishErrorCode, message: string, detail?: Record<string, unknown>) {
super(message);
this.name = "PublishError";
this.code = code;
this.detail = detail;
}
}
export interface PublishLogger {
info?(message: string): void;
success?(message: string): void;
warn?(message: string): void;
}
/**
* Flat identity fields supplied at publish time.
*
* @deprecated Prefer {@link ProfileInput} via `PublishOptions.profileInput`,
* which mirrors the lexicon's profile block (multi-author, multi-security,
* name, description, keywords). This flat shape only models a single author
* and a single security contact and is kept for the deprecated `--author-*` /
* `--security-*` CLI flags and existing programmatic callers. When both
* `profileInput` and `profile` are passed, `profileInput` wins.
*/
export interface ProfileBootstrap {
/** SPDX license expression. Required on first publish. */
license?: string;
authorName?: string;
authorUrl?: string;
authorEmail?: string;
securityEmail?: string;
securityUrl?: string;
}
/**
* Structured profile block, mirroring the `com.emdashcms.experimental
* .package.profile` lexicon. Used only on first publish, when we bootstrap
* the profile record. On subsequent publishes the existing profile wins; any
* provided fields are ignored, reported under `result.ignoredProfileFields`.
*
* `authors` / `security` are arrays (the lexicon allows 132 authors and
* 18 security contacts). At least one security contact carrying a `url` or
* `email` is required on first publish.
*/
export interface ProfileInput {
/** SPDX license expression. Required on first publish. */
license?: string;
authors?: Array<{ name: string; url?: string; email?: string }>;
security?: Array<{ url?: string; email?: string }>;
name?: string;
description?: string;
keywords?: string[];
/**
* Long-form profile sections (description / installation / faq / changelog
* / security), resolved to inline CommonMark strings. Profile-level: written
* to the profile record on first publish, ignored on subsequent publishes
* like the other profile fields.
*/
sections?: Record<string, string>;
}
/**
* A resolved image artifact ready to embed in the release. The CLI command
* reads the file, computes the checksum, measures the dimensions, and uploads
* the bytes before constructing this; `publishRelease` only writes it.
*/
export interface ReleaseArtifactInput {
url: string;
checksum: string;
contentType: string;
width: number;
height: number;
lang?: string;
}
/**
* Resolved release media artifacts. `icon` / `banner` are single images;
* `screenshots` is the ordered gallery, written verbatim to the lexicon's
* `artifacts.screenshots` array.
*/
export interface ReleaseArtifactsInput {
icon?: ReleaseArtifactInput;
banner?: ReleaseArtifactInput;
screenshots?: ReleaseArtifactInput[];
}
export interface PublishOptions {
/** Authenticated client against the publisher's PDS. */
publisher: PublishingClient;
/** Publisher DID. Used to construct AT URIs for display/output. */
did: Did;
/** The plugin manifest extracted from the tarball. */
manifest: PluginManifest;
/** Multibase-multihash sha2-256 of the tarball bytes. */
checksum: string;
/** Public URL where the tarball is hosted. */
url: string;
/**
* Structured profile block used when bootstrapping a new profile.
* Preferred over `profile`; when both are set, this wins entirely.
*/
profileInput?: ProfileInput;
/**
* Flat identity fields used when bootstrapping a new profile.
*
* @deprecated Pass `profileInput` instead. Retained for the deprecated
* `--author-*` / `--security-*` CLI flags and existing programmatic
* callers. Ignored when `profileInput` is provided.
*/
profile?: ProfileBootstrap;
/**
* Source-repository URL for this release (`release.repo` in the
* lexicon). Written to every release record when set — releases are
* immutable per version, so this is not a first-publish-only field.
*/
repo?: string;
/**
* Environment constraints for this release (`release.requires` in the
* lexicon). Map of `env:*`/DID keys to semver ranges. Written to the
* release record when non-empty; omitted otherwise.
*/
requires?: Record<string, string>;
/**
* Resolved media artifacts (icon / screenshot / banner) for this release.
* Already uploaded and measured by the caller. Written verbatim into the
* release record. Releases are immutable per version, so this is not a
* first-publish-only field.
*/
artifacts?: ReleaseArtifactsInput;
/**
* Allow overwriting an existing release at `<slug>:<version>`. Default
* is `false`, which causes publish to refuse with `RELEASE_ALREADY_PUBLISHED`.
*/
allowOverwrite?: boolean;
/** Optional progress reporter. */
logger?: PublishLogger;
}
export interface PublishResult {
/** AT URI of the package profile record (created or existing). */
profileUri: string;
/** AT URI of the release record. */
releaseUri: string;
/** CID of the release record commit. */
releaseCid: string;
/** Multibase-multihash echoed back for convenience. */
checksum: string;
/** True if this publish created the profile; false if it reused an existing one. */
profileCreated: boolean;
/** True if this publish overwrote an existing release record at the same rkey. */
releaseOverwritten: boolean;
/** Computed slug (from manifest id). */
slug: string;
/**
* Names of profile fields the caller passed that were ignored because the
* profile already existed. Empty on first publish.
*/
ignoredProfileFields: string[];
}
// ──────────────────────────────────────────────────────────────────────────
// Implementation
// ──────────────────────────────────────────────────────────────────────────
/**
* Lexicon types use atcute's branded template-literal types (`ResourceUri`,
* `${string}:${string}`, etc.) for fields with format constraints. Those
* make on-the-wire records hard to construct from raw runtime strings
* without a `safeParse` round-trip.
*
* We build records here against looser local shapes that mirror the lexicon
* JSON exactly; the PDS validates server-side via `validate: true` (set in
* the publishing client). The static assertions on `RegistryRecords` would
* be one obvious thing to add here, but the lexicon-derived `Main` types
* are *strict subtypes* of these shapes (because of the branded URLs) --
* not supertypes -- so they aren't useful as guard rails. The validation we
* rely on is:
*
* - publish-time: `isPluginSlug` + `isPluginVersion` against the lexicon
* constraints, before any record construction.
* - put-time: PDS lexicon validation via `validate: true`.
*
* For untrusted inputs (records read back from a PDS) callers should run
* the lexicon's `mainSchema.safeParse` themselves.
*/
interface PackageProfileRecordShape {
$type: typeof NSID.packageProfile;
id: string;
type: "emdash-plugin" | (string & {});
license: string;
authors: Array<{ name: string; url?: string; email?: string }>;
security: Array<{ url?: string; email?: string }>;
slug: string;
lastUpdated: string;
name?: string;
description?: string;
keywords?: string[];
sections?: Record<string, string>;
}
/** An image artifact embedded in a release (`release.json#artifact`). */
interface ImageArtifact {
url: string;
checksum: string;
contentType: string;
width: number;
height: number;
lang?: string;
}
interface PackageReleaseRecordShape {
$type: typeof NSID.packageRelease;
package: string;
version: string;
artifacts: {
package: {
url: string;
checksum: string;
contentType?: string;
};
icon?: ImageArtifact;
banner?: ImageArtifact;
/** Ordered screenshot gallery (`artifacts.screenshots` in the lexicon). */
screenshots?: ImageArtifact[];
};
/** Source-repository URL (`release.repo`). Omitted when not provided. */
repo?: string;
/** Environment constraints (`release.requires`). Omitted when empty. */
requires?: Record<string, string>;
/**
* Open-union extension container, keyed by NSID. Releases of type
* `emdash-plugin` MUST include a `releaseExtension` entry carrying the
* sandbox trust contract (declared access). Without it, sandbox runtimes
* have no contract to enforce against.
*/
extensions: Record<string, unknown>;
}
interface FetchedRecord {
uri: string;
cid: string;
value: unknown;
}
export async function publishRelease(options: PublishOptions): Promise<PublishResult> {
const log = options.logger ?? {};
// 1. Synchronous, network-free validation runs first so we fail fast.
const deprecated = options.manifest.capabilities.filter(isDeprecatedCapability);
if (deprecated.length > 0) {
throw new PublishError(
"DEPRECATED_CAPABILITY",
`Plugin uses deprecated capability names: ${deprecated.join(", ")}. Rename them before publishing.`,
{ deprecated },
);
}
const slug = deriveSlugFromId(options.manifest.id);
if (!isPluginSlug(slug)) {
throw new PublishError(
"INVALID_SLUG",
`Plugin id "${options.manifest.id}" produces slug "${slug}" which doesn't match the lexicon constraint /^[a-z][a-z0-9_-]*$/ (max 64 chars). Rename the plugin id.`,
{ id: options.manifest.id, slug },
);
}
if (!isPluginVersion(options.manifest.version)) {
throw new PublishError(
"INVALID_VERSION",
`Plugin version "${options.manifest.version}" is not a valid semver (max 64 chars; semver build-metadata "+..." disallowed because the atproto rkey alphabet has no "+").`,
{ version: options.manifest.version },
);
}
// Refuse `network:request` with no allowedHosts. The lexicon defines
// `request: {}` (no allowedHosts) as "unrestricted requests" -- but the
// `network:request` capability name was meant to be host-restricted.
// Rather than silently publish a record that says "unrestricted" while
// the bundler told the developer "all requests will be blocked",
// require the publisher to be explicit: declare hosts, or upgrade to
// `network:request:unrestricted`.
const normalizedCaps = options.manifest.capabilities.map((c) => normalizeCapability(c));
if (
normalizedCaps.includes("network:request") &&
!normalizedCaps.includes("network:request:unrestricted") &&
options.manifest.allowedHosts.length === 0
) {
throw new PublishError(
"INVALID_MANIFEST",
"Plugin declares `network:request` capability but no `allowedHosts`. Either list specific host patterns in `allowedHosts`, or upgrade to `network:request:unrestricted` if the plugin really needs to call any host.",
{ capabilities: normalizedCaps, allowedHosts: options.manifest.allowedHosts },
);
}
// Validate profile-bootstrap fields up-front. We don't yet know whether the
// profile already exists (one round-trip away), but if the user supplied
// no fields at all and they're needed, we can fail before the network.
// (We can't fail early when fields are missing-but-required-only-on-first-
// publish, since that needs the existence check.)
const profileUri = atUri(options.did, NSID.packageProfile, slug);
const releaseRkey = `${slug}:${options.manifest.version}`;
// 2. Read existing profile + release in parallel. Either may be absent.
const [existingProfile, existingRelease] = await Promise.all([
getRecordOrNull(options.publisher, NSID.packageProfile, slug),
getRecordOrNull(options.publisher, NSID.packageRelease, releaseRkey),
]);
// 3. Refuse to overwrite an existing release unless asked.
if (existingRelease !== null && !options.allowOverwrite) {
throw new PublishError(
"RELEASE_ALREADY_PUBLISHED",
`Release ${slug}@${options.manifest.version} is already published. ` +
"FAIR specifies that version records are immutable; aggregators and " +
"labellers may treat any change as a takedown event. " +
"Pass allowOverwrite: true to overwrite anyway.",
{ slug, version: options.manifest.version },
);
}
const releaseOverwritten = existingRelease !== null;
if (releaseOverwritten) {
log.warn?.(
`Overwriting existing release ${slug}@${options.manifest.version}. ` +
"Consumers who already installed this version will keep the old bytes; " +
"aggregators may flag the change.",
);
}
// 4. Build the operations list. We always write the release; the profile
// is created on first publish or `lastUpdated`-bumped on subsequent.
const profileCreated = existingProfile === null;
const ignoredProfileFields: string[] = [];
// The EmDash trust extension carries the manifest's declaredAccess
// verbatim -- the lexicon REQUIRES it on every emdash-plugin release, and
// the install-time deep-equal compares the bundle's declaredAccess against
// it. A tarball built before the wire format carried declaredAccess has it
// derived from the (normalized) legacy capability list as a fallback.
const declaredAccess =
options.manifest.declaredAccess ??
capabilitiesToDeclaredAccess(normalizedCaps, options.manifest.allowedHosts);
const releaseExtension = {
$type: NSID.packageReleaseExtension,
declaredAccess,
};
const releaseRecord: PackageReleaseRecordShape = {
$type: NSID.packageRelease,
package: slug,
version: options.manifest.version,
artifacts: {
package: {
url: options.url,
checksum: options.checksum,
contentType: "application/gzip",
},
},
extensions: {
[NSID.packageReleaseExtension]: releaseExtension,
},
};
if (options.repo !== undefined) {
releaseRecord.repo = options.repo;
}
if (options.requires !== undefined && Object.keys(options.requires).length > 0) {
releaseRecord.requires = options.requires;
}
applyArtifacts(releaseRecord, options.artifacts);
type WriteOp =
| {
op: "create";
collection: typeof NSID.packageProfile;
rkey: string;
record: PackageProfileRecordShape;
}
| {
op: "update";
collection: typeof NSID.packageProfile;
rkey: string;
record: PackageProfileRecordShape;
}
| {
op: "create";
collection: typeof NSID.packageRelease;
rkey: string;
record: PackageReleaseRecordShape;
}
| {
op: "update";
collection: typeof NSID.packageRelease;
rkey: string;
record: PackageReleaseRecordShape;
};
const writes: WriteOp[] = [];
// `profileInput` (structured, mirrors the lexicon) wins over the
// deprecated flat `profile`. We keep the raw inputs around for the
// ignored-fields report so a flat-flag caller still sees flat field
// names in the warning.
const usedStructured = options.profileInput !== undefined;
const resolvedProfile = resolveProfileInput(options);
if (profileCreated) {
const profileRecord = buildProfileRecord({
slug,
profileUri,
profile: resolvedProfile,
});
writes.push({
op: "create",
collection: NSID.packageProfile,
rkey: slug,
record: profileRecord,
});
log.info?.(`Bootstrapping profile: ${profileUri}`);
} else {
ignoredProfileFields.push(
...(usedStructured
? listProvidedProfileInputFields(options.profileInput)
: listProvidedProfileFields(options.profile)),
);
// Bump `lastUpdated` on the existing profile so aggregators ordering
// by it see this publish. The user's first-publish-only flags are
// still ignored (the existing profile owns identity/license/security),
// but the timestamp follows the latest release. We round-trip the
// existing record to preserve every other field byte-for-byte.
const stamped = stampLastUpdated(existingProfile.value);
if (stamped !== null) {
writes.push({
op: "update",
collection: NSID.packageProfile,
rkey: slug,
record: stamped,
});
log.info?.(`Reusing profile (bumping lastUpdated): ${profileUri}`);
} else {
// Existing profile didn't validate enough to construct a typed
// shape; leave it alone and emit a warning.
log.warn?.(
`Existing profile at ${profileUri} doesn't match the lexicon shape; lastUpdated not bumped.`,
);
}
}
writes.push({
op: releaseOverwritten ? "update" : "create",
collection: NSID.packageRelease,
rkey: releaseRkey,
record: releaseRecord,
});
// 5. Validate every record locally against its lexicon BEFORE round-
// tripping. We can't rely on the PDS to validate because the experimental
// registry NSIDs aren't shipped with most PDSes -- a real Bluesky PDS
// rejects a `validate: true` write of an unknown lexicon. So we own the
// validation and pass `skipValidation: true` to applyWrites.
for (const op of writes) {
validateLocally(op.collection, op.record);
}
// Also validate the embedded extension record. Lexicon-level $type
// dispatch happens at the host parsing the release record's extensions
// map; we want to fail-fast here so a malformed extension doesn't silently
// reach the registry.
validateLocally(NSID.packageReleaseExtension, releaseExtension);
// 6. Apply atomically. `skipValidation: true` because we've already
// validated locally and the PDS doesn't know our experimental lexicons.
//
// We do NOT pass `swapCommit` here. `swapCommit` provides optimistic-CAS
// semantics by failing the write if the repo's current head CID differs
// from a CID we observed earlier. The use case it protects against --
// "another publisher concurrently updated the same record" -- doesn't
// exist for our flow: each repo has exactly one publisher (the human
// running the CLI under their own DID), and the read-then-write race is
// against themselves. The cost of adding swapCommit (an extra
// `getRepo`/`describeRepo` round-trip per publish to learn the head CID)
// isn't worth it for a single-user repo. If we ever support multi-agent
// publishing to a shared registry repo, revisit.
const batch = await options.publisher.applyWrites({
skipValidation: true,
writes: writes as unknown as Parameters<typeof options.publisher.applyWrites>[0]["writes"],
});
// The release result is always the last in the input order.
const releaseOpResult = batch.results.at(-1);
if (!releaseOpResult || (releaseOpResult.op !== "create" && releaseOpResult.op !== "update")) {
// Defensive: applyWrites should always echo a create/update result for a
// create/update operation. If we get back a delete or nothing, something
// is very wrong.
throw new Error(
"applyWrites returned no result for the release operation (expected create/update).",
);
}
if (profileCreated) {
log.success?.(`Created profile: ${profileUri}`);
}
return {
profileUri,
releaseUri: releaseOpResult.uri,
releaseCid: releaseOpResult.cid,
checksum: options.checksum,
profileCreated,
releaseOverwritten,
slug,
ignoredProfileFields,
};
}
// ──────────────────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────────────────
function atUri(did: Did, collection: string, rkey: string): string {
return `at://${did}/${collection}/${rkey}`;
}
/**
* Write resolved media artifacts into a release record's `artifacts` map.
*
* `icon` and `banner` map to their single-`#artifact` lexicon slots directly;
* `screenshots` is written as the lexicon's `artifacts.screenshots` array,
* preserving gallery order.
*/
function applyArtifacts(
record: PackageReleaseRecordShape,
artifacts: ReleaseArtifactsInput | undefined,
): void {
if (!artifacts) return;
if (artifacts.icon) record.artifacts.icon = { ...artifacts.icon };
if (artifacts.banner) record.artifacts.banner = { ...artifacts.banner };
if (artifacts.screenshots && artifacts.screenshots.length > 0) {
record.artifacts.screenshots = artifacts.screenshots.map((shot) => ({ ...shot }));
}
}
/**
* Validate `value` against the lexicon for `collection`. Throws
* `PublishError("LEXICON_VALIDATION_FAILED")` on failure. Skips validation
* for collections we don't own a schema for (caller's responsibility).
*
* We validate locally rather than trusting the PDS because experimental
* registry NSIDs aren't shipped with most PDSes -- a real Bluesky PDS would
* reject a write of `com.emdashcms.experimental.*` records when
* `validate: true`.
*
* CAVEAT: atcute's `v.object` accepts unknown keys silently (it copies the
* declared shape and ignores extras). The lexicon prose for declaredAccess
* mandates "clients MUST reject release records that include unrecognised
* top-level fields", but this validator does not enforce that rule -- we
* rely on the fact that we construct the records ourselves and never
* inject unknown keys. Aggregators MUST do their own strict validation;
* don't treat `validateLocally` returning ok as proof the record is
* spec-compliant.
*/
function validateLocally(collection: string, value: unknown): void {
const schemas: Record<string, { mainSchema: Parameters<typeof safeParse>[0] } | undefined> = {
[NSID.packageProfile]: PackageProfile,
[NSID.packageRelease]: PackageRelease,
[NSID.packageReleaseExtension]: PackageReleaseExtension,
};
const ns = schemas[collection];
if (!ns) return;
const result = safeParse(ns.mainSchema, value);
if (result.ok) return;
throw new PublishError(
"LEXICON_VALIDATION_FAILED",
`Record for ${collection} did not match the lexicon. Issues: ${formatValidationIssues(result)}`,
{ collection, issues: result },
);
}
function formatValidationIssues(err: unknown): string {
// JSON-serialise whatever the validator handed us (typically a result
// object with an `issues` array). Falls back to a JSON-stringification
// of the whole value if the expected fields aren't there. We never call
// `String(err)` on an unknown object because that produces the
// useless `[object Object]`.
try {
if (err && typeof err === "object") {
const obj = err as { issues?: unknown; message?: unknown };
if (obj.issues !== undefined) return JSON.stringify(obj.issues);
if (typeof obj.message === "string") return obj.message;
return JSON.stringify(err);
}
if (typeof err === "string") return err;
return JSON.stringify(err);
} catch {
// Circular structure or similar; fall back to the type-tag.
return Object.prototype.toString.call(err);
}
}
/**
* Fetch a record, returning `null` if the PDS reports it as missing.
*
* Returns the full `{ uri, cid, value }` shape (rather than the value alone)
* so callers that need the existing CID for `swapRecord` semantics can get
* it. The publish flow distinguishes "no record" from "record with falsy
* value" via the `null` sentinel; checking truthiness of the value would
* misfire on a legitimate-but-falsy stored value.
*/
async function getRecordOrNull(
publisher: PublishingClient,
collection: Nsid,
rkey: string,
): Promise<FetchedRecord | null> {
try {
const record = await publisher.getRecord({ collection, rkey });
return { uri: record.uri, cid: record.cid, value: record.value };
} catch (error) {
if (error instanceof ClientResponseError && error.error === "RecordNotFound") {
return null;
}
throw error;
}
}
/**
* Return a copy of the existing profile record value with `lastUpdated`
* bumped to now. Returns `null` if the existing record doesn't have the
* fields we need to round-trip safely (in which case the caller skips the
* update rather than overwriting an invalid record with a slightly-different
* invalid record).
*
* Unknown / extra fields on the existing record are intentionally preserved
* verbatim via the spread. If they violate the lexicon, the local
* `validateLocally` pass before `applyWrites` will reject the candidate
* with a `LEXICON_VALIDATION_FAILED` error rather than letting an invalid
* record propagate to the registry.
*/
function stampLastUpdated(existingValue: unknown): PackageProfileRecordShape | null {
if (!existingValue || typeof existingValue !== "object") return null;
const v = existingValue as Record<string, unknown>;
if (typeof v.id !== "string") return null;
if (typeof v.type !== "string") return null;
if (typeof v.license !== "string") return null;
if (!Array.isArray(v.authors)) return null;
if (!Array.isArray(v.security)) return null;
if (typeof v.slug !== "string") return null;
// Re-emit only schema-known fields. A spread over `v` would carry
// across unknown keys (e.g. fields from a since-removed earlier
// experimental shape), which `validateLocally` accepts silently
// (atcute's v.object ignores extras, see its caveat doc) but
// strict-validating aggregators reject. Whitelisting here is the
// canonicalising step.
const candidate: Record<string, unknown> = {
$type: NSID.packageProfile,
id: v.id,
type: v.type,
license: v.license,
authors: v.authors,
security: v.security,
slug: v.slug,
lastUpdated: new Date().toISOString(),
};
// Optional fields only if present and well-typed -- otherwise drop.
if (typeof v.name === "string") candidate.name = v.name;
if (typeof v.description === "string") candidate.description = v.description;
if (Array.isArray(v.keywords)) candidate.keywords = v.keywords;
if (v.sections && typeof v.sections === "object") candidate.sections = v.sections;
return candidate as unknown as PackageProfileRecordShape;
}
function buildProfileRecord(input: {
slug: string;
profileUri: string;
profile: ProfileInput;
}): PackageProfileRecordShape {
const profile = input.profile;
if (!profile.license) {
throw new PublishError(
"PROFILE_BOOTSTRAP_MISSING_FIELD",
"license is required on first publish (e.g. MIT). The lexicon requires a SPDX license expression for every package.",
{ field: "license" },
);
}
// Drop entries the lexicon would reject (a contact MUST carry url or
// email) and re-build clean objects so an undefined key never reaches
// the record.
const security: Array<{ url?: string; email?: string }> = [];
for (const c of profile.security ?? []) {
const entry: { url?: string; email?: string } = {};
if (c.email) entry.email = c.email;
if (c.url) entry.url = c.url;
if (entry.email || entry.url) security.push(entry);
}
if (security.length === 0) {
throw new PublishError(
"PROFILE_BOOTSTRAP_MISSING_FIELD",
"at least one security contact with a url or email is required on first publish. Clients refuse to install packages without a security contact.",
{ field: "security" },
);
}
const authors: Array<{ name: string; url?: string; email?: string }> = [];
for (const a of profile.authors ?? []) {
const entry: { name: string; url?: string; email?: string } = { name: a.name };
if (a.url) entry.url = a.url;
if (a.email) entry.email = a.email;
authors.push(entry);
}
// The lexicon requires at least one author. A caller that supplied no
// authors (e.g. the deprecated flag path with no --author-* flags) gets
// a single placeholder, preserving prior behaviour.
if (authors.length === 0) authors.push({ name: "unknown" });
const record: PackageProfileRecordShape = {
$type: NSID.packageProfile,
id: input.profileUri,
type: "emdash-plugin",
license: profile.license,
authors,
security,
slug: input.slug,
lastUpdated: new Date().toISOString(),
};
if (profile.name !== undefined) record.name = profile.name;
if (profile.description !== undefined) record.description = profile.description;
if (profile.keywords !== undefined && profile.keywords.length > 0) {
record.keywords = profile.keywords;
}
if (profile.sections !== undefined && Object.keys(profile.sections).length > 0) {
record.sections = profile.sections;
}
return record;
}
/**
* Resolve the effective profile block. `profileInput` (structured, mirrors
* the lexicon) wins entirely when present; otherwise the deprecated flat
* `profile` is adapted to the same shape so the rest of the pipeline only
* deals with `ProfileInput`.
*/
function resolveProfileInput(options: PublishOptions): ProfileInput {
if (options.profileInput !== undefined) return options.profileInput;
return profileBootstrapToInput(options.profile);
}
function profileBootstrapToInput(flat: ProfileBootstrap | undefined): ProfileInput {
const b = flat ?? {};
const input: ProfileInput = {};
if (b.license !== undefined) input.license = b.license;
if (b.authorName !== undefined || b.authorUrl !== undefined || b.authorEmail !== undefined) {
const a: { name: string; url?: string; email?: string } = { name: b.authorName ?? "unknown" };
if (b.authorUrl) a.url = b.authorUrl;
if (b.authorEmail) a.email = b.authorEmail;
input.authors = [a];
}
if (b.securityEmail !== undefined || b.securityUrl !== undefined) {
const c: { url?: string; email?: string } = {};
if (b.securityEmail) c.email = b.securityEmail;
if (b.securityUrl) c.url = b.securityUrl;
input.security = [c];
}
return input;
}
/**
* Names of structured profile fields the caller supplied, for the
* ignored-on-subsequent-publish report. A field counts as provided when it
* is set and (for arrays) non-empty.
*/
function listProvidedProfileInputFields(input: ProfileInput | undefined): string[] {
if (!input) return [];
const fields: string[] = [];
if (input.license !== undefined) fields.push("license");
if (input.name !== undefined) fields.push("name");
if (input.description !== undefined) fields.push("description");
if (input.keywords !== undefined && input.keywords.length > 0) fields.push("keywords");
if (input.authors !== undefined && input.authors.length > 0) fields.push("authors");
if (input.security !== undefined && input.security.length > 0) fields.push("security");
if (input.sections !== undefined && Object.keys(input.sections).length > 0) {
fields.push("sections");
}
return fields;
}
/**
* Returns the names of any profile-bootstrap fields the caller supplied. Used
* to report fields that were ignored because the profile already existed.
*
* Iterates the keys of `ProfileBootstrap` explicitly so that future numeric /
* boolean / non-string fields don't silently disappear from the warning.
*/
function listProvidedProfileFields(profile: ProfileBootstrap | undefined): string[] {
if (!profile) return [];
const fields: Array<keyof ProfileBootstrap> = [
"license",
"authorName",
"authorUrl",
"authorEmail",
"securityEmail",
"securityUrl",
];
return fields.filter((name) => profile[name] !== undefined);
}
@@ -0,0 +1,121 @@
/**
* Media-artifact resolution for the publish flow.
*
* Given the bytes of an image file and the public URL it's hosted at, build
* the `#artifact` record the release embeds: the multibase-multihash checksum,
* the MIME content type, and the pixel dimensions. Dimensions come from
* `image-size`, which reads only the header bytes (no decode), so it's cheap
* and works for PNG / JPEG / WebP / GIF / AVIF.
*
* Kept filesystem- and network-free so it tests against raw byte fixtures.
* The CLI command reads files and uploads them; this module turns bytes +
* URL into a record.
*/
import { imageSize } from "image-size";
import { sha256Multihash } from "../multihash.js";
/** An artifact record ready to embed in a release. Mirrors `release.json#artifact`. */
export interface ArtifactRecord {
url: string;
checksum: string;
contentType: string;
width: number;
height: number;
lang?: string;
}
/**
* Image formats `image-size` reports that we accept as plugin artifacts, mapped
* to their canonical MIME type. The `type` field is the format name from the
* header sniff; we don't trust a file extension for the content type.
*/
/** Per-dimension pixel ceiling, matching `release.json#artifact.width/height`. */
const MAX_ARTIFACT_DIMENSION = 8192;
const TYPE_TO_CONTENT_TYPE: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
avif: "image/avif",
};
/** Thrown when an artifact file isn't a supported image. */
export class ArtifactError extends Error {
override readonly name = "ArtifactError";
readonly code: "ARTIFACT_UNSUPPORTED" | "ARTIFACT_UNREADABLE";
constructor(code: "ARTIFACT_UNSUPPORTED" | "ARTIFACT_UNREADABLE", message: string) {
super(message);
this.code = code;
}
}
/**
* Sniff `bytes` as an image and return its content type and dimensions. Throws
* `ArtifactError` when the bytes aren't a supported image or carry no usable
* dimensions.
*/
export function measureImage(bytes: Uint8Array): {
contentType: string;
width: number;
height: number;
} {
let result: { width?: number; height?: number; type?: string };
try {
result = imageSize(bytes);
} catch (error) {
throw new ArtifactError(
"ARTIFACT_UNREADABLE",
`Artifact is not a recognised image: ${error instanceof Error ? error.message : String(error)}`,
);
}
const type = result.type;
if (type === undefined || !(type in TYPE_TO_CONTENT_TYPE)) {
throw new ArtifactError(
"ARTIFACT_UNSUPPORTED",
`Artifact image format ${type ? `"${type}"` : "(unknown)"} is not supported. Use PNG, JPEG, WebP, GIF, or AVIF.`,
);
}
const { width, height } = result;
if (typeof width !== "number" || typeof height !== "number" || width < 1 || height < 1) {
throw new ArtifactError(
"ARTIFACT_UNSUPPORTED",
"Artifact image has no readable pixel dimensions.",
);
}
// The lexicon caps artifact dimensions at 8192px; a larger image would be
// rejected at publish-time lexicon validation with an opaque error.
if (width > MAX_ARTIFACT_DIMENSION || height > MAX_ARTIFACT_DIMENSION) {
throw new ArtifactError(
"ARTIFACT_UNSUPPORTED",
`Artifact image is ${width}x${height}px; each dimension must be <= ${MAX_ARTIFACT_DIMENSION}px.`,
);
}
return { contentType: TYPE_TO_CONTENT_TYPE[type]!, width, height };
}
/**
* Build the `#artifact` record for an image hosted at `url`. Computes the
* checksum from the same bytes the consumer will fetch, and reads the
* dimensions and content type from the header. `lang` is carried through when
* the manifest set it.
*/
export function buildArtifactRecord(input: {
bytes: Uint8Array;
url: string;
lang?: string;
}): ArtifactRecord {
const { contentType, width, height } = measureImage(input.bytes);
const record: ArtifactRecord = {
url: input.url,
checksum: sha256Multihash(input.bytes),
contentType,
width,
height,
};
if (input.lang !== undefined) record.lang = input.lang;
return record;
}
@@ -0,0 +1,211 @@
/**
* Resolve, upload, and record a manifest's media artifacts.
*
* The manifest declares artifacts as file refs (`{ file: "./icon.png" }`)
* relative to itself. At publish time the CLI:
*
* 1. resolves each ref under the manifest directory (rejecting paths that
* escape it),
* 2. reads the bytes and measures content type + dimensions,
* 3. PUTs the bytes to `<base>/<slug>/<version>/<slot>-<filename>`,
* 4. records `{ url, checksum, contentType, width, height, lang? }`.
*
* The hosting contract: the publisher's `--artifact-base-url` target must
* accept the PUT and serve the same bytes back, unchanged, with a stable
* content type, at the URL we record. Consumers fetch through the EmDash
* server's SSRF-defended proxy.
*/
import { readFile } from "node:fs/promises";
import { basename, isAbsolute, relative, resolve, sep } from "node:path";
import type { ManifestArtifacts, ManifestArtifactFile } from "../manifest/schema.js";
import type { ReleaseArtifactInput, ReleaseArtifactsInput } from "./api.js";
import { ArtifactError, buildArtifactRecord } from "./artifacts.js";
/** Hard cap on a single artifact file, so a runaway image can't OOM the CLI. */
const MAX_ARTIFACT_BYTES = 2 * 1024 * 1024;
/** Strip trailing slashes from the artifact base URL. */
const TRAILING_SLASHES = /\/+$/;
export interface ResolveArtifactsOptions {
/** Parsed `release.artifacts` block, or `undefined` when none declared. */
artifacts: ManifestArtifacts | undefined;
/** Absolute path to the directory containing the manifest. */
manifestDir: string;
/** Base URL the CLI PUTs artifact bytes to (no trailing slash required). */
baseUrl: string;
/** Plugin slug, used in the upload path. */
slug: string;
/** Release version, used in the upload path. */
version: string;
/** Optional progress reporter. */
logger?: { info?(m: string): void; success?(m: string): void };
/**
* Injectable uploader. Defaults to an HTTP PUT. Tests pass a stub so the
* resolve flow runs without a network.
*/
upload?: ArtifactUploader;
}
/**
* Uploads `bytes` to `url` with the given content type and resolves once the
* bytes are durably stored. Throws on any non-success.
*/
export type ArtifactUploader = (input: {
url: string;
bytes: Uint8Array;
contentType: string;
}) => Promise<void>;
/** Thrown when artifact resolution or upload fails. */
export class ArtifactUploadError extends Error {
override readonly name = "ArtifactUploadError";
readonly code: string;
constructor(code: string, message: string) {
super(message);
this.code = code;
}
}
/**
* Resolve every declared artifact to an embeddable record, uploading the bytes
* along the way. Returns `undefined` when the manifest declared no artifacts.
*/
export async function resolveReleaseArtifacts(
options: ResolveArtifactsOptions,
): Promise<ReleaseArtifactsInput | undefined> {
const { artifacts } = options;
if (!artifacts) return undefined;
if (!artifacts.icon && !artifacts.banner && !(artifacts.screenshots?.length ?? 0)) {
return undefined;
}
const upload = options.upload ?? httpPutUploader;
const out: ReleaseArtifactsInput = {};
if (artifacts.icon) {
out.icon = await resolveOne(artifacts.icon, "icon", "icon", options, upload);
}
if (artifacts.banner) {
out.banner = await resolveOne(artifacts.banner, "banner", "banner", options, upload);
}
if (artifacts.screenshots && artifacts.screenshots.length > 0) {
const screenshots: ReleaseArtifactInput[] = [];
for (const [index, ref] of artifacts.screenshots.entries()) {
screenshots.push(
await resolveOne(
ref,
`screenshot ${index + 1}`,
`screenshot-${index + 1}`,
options,
upload,
),
);
}
out.screenshots = screenshots;
}
return out;
}
async function resolveOne(
ref: ManifestArtifactFile,
label: string,
slot: string,
options: ResolveArtifactsOptions,
upload: ArtifactUploader,
): Promise<ReleaseArtifactInput> {
const absolute = resolveWithinManifest(options.manifestDir, ref.file, label);
let bytes: Uint8Array;
try {
bytes = await readFile(absolute);
} catch (error) {
throw new ArtifactUploadError(
"ARTIFACT_FILE_UNREADABLE",
`Could not read ${label} artifact at ${ref.file}: ${error instanceof Error ? error.message : String(error)}`,
);
}
if (bytes.length > MAX_ARTIFACT_BYTES) {
throw new ArtifactUploadError(
"ARTIFACT_TOO_LARGE",
`${label} artifact ${ref.file} is ${bytes.length} bytes, exceeding the ${MAX_ARTIFACT_BYTES}-byte limit.`,
);
}
let record;
try {
record = buildArtifactRecord({
bytes,
url: artifactUrl(options.baseUrl, options.slug, options.version, slot, ref.file),
lang: ref.lang,
});
} catch (error) {
if (error instanceof ArtifactError) {
throw new ArtifactUploadError(error.code, `${label} artifact: ${error.message}`);
}
throw error;
}
options.logger?.info?.(`Uploading ${label} (${record.width}x${record.height}) -> ${record.url}`);
try {
await upload({ url: record.url, bytes, contentType: record.contentType });
} catch (error) {
throw new ArtifactUploadError(
"ARTIFACT_UPLOAD_FAILED",
`Failed to upload ${label} artifact to ${record.url}: ${error instanceof Error ? error.message : String(error)}`,
);
}
options.logger?.success?.(`Uploaded ${label}`);
return record;
}
/**
* Resolve `file` under `manifestDir` and refuse paths that escape it (via
* `..` or an absolute path). The manifest is publisher-authored, but a
* traversal would let `publish` read arbitrary files off the machine running
* the CLI and upload them, so the boundary is enforced.
*/
function resolveWithinManifest(manifestDir: string, file: string, label: string): string {
const absolute = resolve(manifestDir, file);
const rel = relative(manifestDir, absolute);
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(file)) {
throw new ArtifactUploadError(
"ARTIFACT_PATH_ESCAPE",
`${label} artifact path ${file} resolves outside the manifest directory.`,
);
}
return absolute;
}
/**
* Build the public URL for an artifact:
* `<base>/<slug>/<version>/<slot>-<filename>`. The basename of the manifest ref
* keeps nested source paths out of the published URL; the `slot` prefix
* (`icon`, `banner`, `screenshot-2`) keeps two refs with the same basename in
* different directories from colliding on the same upload target.
*/
function artifactUrl(
baseUrl: string,
slug: string,
version: string,
slot: string,
file: string,
): string {
const trimmed = baseUrl.replace(TRAILING_SLASHES, "");
const name = `${slot}-${basename(file)}`;
return `${trimmed}/${encodeURIComponent(slug)}/${encodeURIComponent(version)}/${encodeURIComponent(name)}`;
}
const httpPutUploader: ArtifactUploader = async ({ url, bytes, contentType }) => {
const response = await fetch(url, {
method: "PUT",
headers: { "Content-Type": contentType },
body: bytes,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`);
}
};
@@ -0,0 +1,559 @@
/**
* Programmatic package-update API.
*
* Reads the publisher's existing `com.emdashcms.experimental.package.profile`
* record (the per-package metadata record in the registry lexicon — distinct
* from the publisher's atproto profile at `app.bsky.actor.profile`), diffs
* the lexicon-controlled fields against a manifest-derived candidate, and
* (when applied) writes the new record via `com.atproto.repo.putRecord`.
*
* Splits cleanly from the CLI command so tests can run it against a mock
* `PublishingClient` without going through OAuth or the filesystem.
*
* Scope
* -----
*
* Only fields the manifest controls are eligible for update: `license`,
* `authors`, `security`, `name`, `description`, `keywords`, `sections`.
* Required fields are diffed and written; optional fields (`name`,
* `description`, `keywords`, `sections`) follow a "manifest absent = no
* change" policy so removing a manifest key doesn't silently wipe a
* published value. Identity fields (`$type`, `id`, `slug`, `type`) are
* preserved verbatim. `lastUpdated` is auto-set to now whenever there are
* changes to apply. Unknown fields from a future lexicon revision pass
* through unchanged
* so a CLI from an older revision doesn't silently drop forward-compatible
* data on a write-back.
*
* Concurrency: the read-then-write uses atproto's `swapRecord` CID-based
* CAS precondition. Concurrent writes between read and write surface as
* `STALE_RECORD` rather than silently overwriting the other writer.
*
* Failure modes:
*
* - `PACKAGE_NOT_FOUND`: no package record exists at the manifest's slug
* and the publisher has no other packages either. The user must run
* `publish` first to bootstrap.
* - `POSSIBLE_RENAME`: no record at the manifest's slug, but the publisher
* already has one or more packages at other slugs. Refused so a manifest
* rename doesn't orphan releases under the old slug.
* - `PACKAGE_INVALID`: the existing record doesn't validate against the
* package profile lexicon. We refuse to write rather than overwrite an
* unknown shape with our canonical one.
* - `SLUG_MISMATCH`: defensive guard against the existing record's `slug`
* field differing from the manifest's slug. Aggregators reject records
* where slug doesn't match the rkey, but if a publisher hand-edited the
* record we want a clear refusal before we make it worse.
* - `INVALID_INPUT`: caller input fails the structural checks
* `validateInput` enforces (empty arrays, missing contact details).
* - `STALE_RECORD`: the record was modified between our read and write.
* The caller should re-run to recompute the diff against latest state.
* - `LEXICON_VALIDATION_FAILED`: the merged candidate failed the lexicon
* check (usually because the caller exceeded a length/grapheme cap not
* covered by `validateInput`).
*/
import { ClientResponseError } from "@atcute/client";
import { safeParse } from "@atcute/lexicons/validations";
import type { PublishingClient } from "@emdash-cms/registry-client";
import { NSID, PackageProfile } from "@emdash-cms/registry-lexicons";
// ──────────────────────────────────────────────────────────────────────────
// Public types
// ──────────────────────────────────────────────────────────────────────────
export type UpdatePackageErrorCode =
| "PACKAGE_NOT_FOUND"
| "PACKAGE_INVALID"
| "SLUG_MISMATCH"
| "POSSIBLE_RENAME"
| "INVALID_INPUT"
| "STALE_RECORD"
| "LEXICON_VALIDATION_FAILED";
export class UpdatePackageError extends Error {
override readonly name = "UpdatePackageError";
readonly code: UpdatePackageErrorCode;
readonly detail: Record<string, unknown> | undefined;
constructor(code: UpdatePackageErrorCode, message: string, detail?: Record<string, unknown>) {
super(message);
this.code = code;
this.detail = detail;
}
}
/**
* Package metadata fields the manifest controls. Mirrors the subset of
* `ProfileInput` (from `../publish/api.js`) that update-package actually
* touches. We don't re-import `ProfileInput` directly because that type
* also covers first-publish-only fields and the contract here is "fields
* the manifest can edit after publish".
*/
export interface PackageUpdateInput {
license: string;
authors: Array<{ name: string; url?: string; email?: string }>;
security: Array<{ url?: string; email?: string }>;
name?: string;
description?: string;
keywords?: string[];
sections?: Record<string, string>;
}
/**
* One field's worth of diff information. `before` and `after` are the
* field's raw JSON values (or `undefined` if the field is absent on that
* side). Used for both human display and dry-run JSON output.
*/
export interface PackageFieldDiff {
field: keyof PackageUpdateInput;
before: unknown;
after: unknown;
}
export interface UpdatePackageOptions {
/**
* Authenticated client against the publisher's PDS. The publisher DID
* (used to construct AT URIs for display/output) is read from
* `publisher.did`; we don't accept it as a separate field to avoid the
* disagree-with-publisher footgun.
*/
publisher: PublishingClient;
/** The plugin's slug (rkey of the profile record). */
slug: string;
/** Manifest-derived fields the user wants to apply. */
input: PackageUpdateInput;
/**
* When `false` (the default), compute the diff but DO NOT write. When
* `true`, apply the diff via `putRecord` and bump `lastUpdated`.
*/
apply?: boolean;
/**
* Override the current time used for `lastUpdated`. Defaults to
* `new Date()`. Exposed for tests.
*/
now?: () => Date;
}
export interface UpdatePackageResult {
/** AT URI of the profile record. */
profileUri: string;
/** Per-field diffs. Empty when the manifest matches the existing record. */
diffs: PackageFieldDiff[];
/**
* The candidate record body that would be (or was) written. Only the
* publisher-editable fields here are sourced from the manifest; identity
* and unknown fields are carried over from the existing record.
*/
candidate: Record<string, unknown>;
/**
* True when `apply: true` was passed AND there were diffs. False on dry
* runs and on no-op applies. Use in CLI output to decide between
* "would update" vs "updated".
*/
written: boolean;
/** CID of the written record. Only populated when `written` is true. */
cid?: string;
}
// ──────────────────────────────────────────────────────────────────────────
// Implementation
// ──────────────────────────────────────────────────────────────────────────
/**
* Order in which fields are presented in diffs. Keeps human output stable
* across runs and matches the lexicon's reading order (identity → contacts
* → display).
*/
const FIELD_ORDER: ReadonlyArray<keyof PackageUpdateInput> = [
"license",
"name",
"description",
"keywords",
"authors",
"security",
"sections",
];
export async function updatePackage(options: UpdatePackageOptions): Promise<UpdatePackageResult> {
const did = options.publisher.did;
const profileUri = `at://${did}/${NSID.packageProfile}/${options.slug}`;
// Validate the caller's input against the lexicon's structural rules
// before any network access. The CLI's manifest schema is the primary
// enforcement layer, but the api is exported and programmatic callers
// can submit empty arrays (which the lexicon rejects). Surfacing
// `INVALID_INPUT` here gives them a useful message instead of a later
// `LEXICON_VALIDATION_FAILED` whose default text blames update-package.
const inputError = validateInput(options.input);
if (inputError) {
throw new UpdatePackageError("INVALID_INPUT", inputError, { slug: options.slug });
}
const existing = await fetchExistingProfile(options.publisher, options.slug);
if (existing === null) {
// Distinguish a fresh slug (publisher should run `publish` to
// bootstrap) from a likely rename (the publisher already has a
// package at a different slug; running `publish` would create a
// second package and orphan every release under the old slug).
// We list every sibling rather than singling one out — a publisher
// with several plugins shouldn't see a misleading "you might have
// renamed plugin X" pointer at an unrelated package.
const siblings = await findSiblingPackageSlugs(options.publisher, options.slug);
if (siblings.length > 0) {
const list = siblings.map((s) => `"${s}"`).join(", ");
throw new UpdatePackageError(
"POSSIBLE_RENAME",
`No package at ${profileUri}. The publisher already has package(s) at: ${list}. If you renamed the plugin in your manifest, publishing under the new slug would orphan every release under the old one — revert the slug in emdash-plugin.jsonc, or accept that a rename starts a fresh package and the old releases stay where they are.`,
{ slug: options.slug, existingSlugs: siblings, did },
);
}
throw new UpdatePackageError(
"PACKAGE_NOT_FOUND",
`No package record at ${profileUri}. Run \`emdash-plugin publish\` to create one before editing.`,
{ slug: options.slug, did },
);
}
const existingValue = existing.value;
if (!isPlainObject(existingValue)) {
throw new UpdatePackageError(
"PACKAGE_INVALID",
`Existing profile at ${profileUri} is not a JSON object. Refusing to overwrite an unknown shape.`,
{ slug: options.slug },
);
}
const validation = safeParse(PackageProfile.mainSchema, existingValue);
if (!validation.ok) {
throw new UpdatePackageError(
"PACKAGE_INVALID",
`Existing profile at ${profileUri} does not match the package profile lexicon. Refusing to overwrite. Fix the record directly via your PDS or contact the EmDash team.`,
{ slug: options.slug, issues: validation },
);
}
const existingSlug = typeof existingValue.slug === "string" ? existingValue.slug : options.slug;
if (existingSlug !== options.slug) {
throw new UpdatePackageError(
"SLUG_MISMATCH",
`Existing profile at ${profileUri} has slug "${existingSlug}" but the manifest's slug is "${options.slug}". The slug is the record key and cannot change after publish (it would orphan every release tied to the old slug). To rename a plugin, publish under the new slug as a fresh package.`,
{ existingSlug, manifestSlug: options.slug },
);
}
const now = (options.now ?? defaultNow)();
const { candidate, diffs } = buildPackageCandidate({
existing: existingValue,
input: options.input,
now,
});
if (options.apply !== true || diffs.length === 0) {
return {
profileUri,
diffs,
candidate,
written: false,
};
}
// Local validation before the round-trip. The PDS will reject malformed
// records via `validate: true`, but it doesn't know the experimental
// registry lexicon — so we own the validation and skip server-side.
// `validateInput` covers the empty-arrays / missing-required cases, but
// the lexicon also caps maxLength / maxGraphemes on most fields; a
// programmatic caller exceeding those lands here. The error message
// names both possible causes so the failure isn't auto-attributed to
// update-package's own bookkeeping.
const candidateValidation = safeParse(PackageProfile.mainSchema, candidate);
if (!candidateValidation.ok) {
throw new UpdatePackageError(
"LEXICON_VALIDATION_FAILED",
`Candidate package record did not pass lexicon validation. This is usually caller-supplied input exceeding a lexicon limit (e.g. license max 256 chars, description max 140 graphemes, keywords max 5 entries, author/contact url max 1024 chars). If the input shape is well within those limits, this may indicate a lexicon regression in update-package — please report it. See \`detail.issues\` for the failed checks.`,
{ slug: options.slug, issues: candidateValidation },
);
}
// swapRecord is atproto's CID-based CAS precondition: the write fails
// if the record on the PDS no longer matches the bytes we read at the
// top of this function. Without it, a concurrent edit (another
// update-package invocation, a manual PDS write) between our read and
// our write would silently lose its changes. With it, we surface
// `STALE_RECORD` and the user can re-run.
let put: { uri: string; cid: string };
try {
put = await options.publisher.unsafePutRecord({
collection: NSID.packageProfile,
rkey: options.slug,
record: candidate,
skipValidation: true,
swapRecord: existing.cid,
});
} catch (error) {
if (error instanceof ClientResponseError && error.error === "InvalidSwap") {
throw new UpdatePackageError(
"STALE_RECORD",
`The package record at ${profileUri} was modified by another writer between read and write. Re-run \`emdash-plugin update-package\` to recompute the diff against the latest state and try again.`,
{ slug: options.slug, expectedCid: existing.cid },
);
}
throw error;
}
return {
profileUri,
diffs,
candidate,
written: true,
cid: put.cid,
};
}
// ──────────────────────────────────────────────────────────────────────────
// Pure helpers (exported for tests)
// ──────────────────────────────────────────────────────────────────────────
/**
* Build the candidate package record body and diff it against the
* existing record. Identity fields (`$type`, `id`, `slug`, `type`) and
* any unknown fields on the existing record are carried over verbatim.
*
* Field-update semantics, mirroring `publish` so users get one mental
* model across both commands:
*
* - Required-in-manifest fields (`license`, `authors`, `security`):
* always written from `input`; diffed against existing.
* - Optional-in-manifest fields (`name`, `description`, `keywords`):
* when the manifest sets them, diff and apply. When the manifest
* omits them, the existing value is preserved verbatim — a missing
* manifest key is NOT a request to delete. Removing a manifest key
* by accident shouldn't wipe a value the publisher put there
* deliberately. Clearing a value needs a dedicated mechanism (not
* yet implemented).
*
* `lastUpdated` is set to `now` iff there are diffs; an unchanged record
* keeps the existing timestamp so a no-op update doesn't churn the
* aggregator's lastUpdated ordering.
*/
export function buildPackageCandidate(input: {
existing: Record<string, unknown>;
input: PackageUpdateInput;
now: Date;
}): { candidate: Record<string, unknown>; diffs: PackageFieldDiff[] } {
const next = normaliseInput(input.input);
const diffs: PackageFieldDiff[] = [];
const candidate: Record<string, unknown> = { ...input.existing };
for (const field of FIELD_ORDER) {
const before = input.existing[field];
const after = next[field];
if (after === undefined) {
// Manifest didn't supply this field. Preserve the existing
// value verbatim — see the docstring's "no missing-equals-
// delete" rule.
continue;
}
if (!deepEqual(before, after)) {
candidate[field] = after;
diffs.push({ field, before, after });
}
}
// lastUpdated is auto-managed: bumped only when something changed.
if (diffs.length > 0) {
candidate.lastUpdated = input.now.toISOString();
}
return { candidate, diffs };
}
/**
* Normalise the manifest-derived input so the diff sees the same canonical
* shape we'd write. Strips `undefined` keys from author/contact entries so
* the structural equality check matches the cleaned PDS form (PDS reads
* never return `undefined` values, but a programmatic caller's input may).
* Optional fields (`name`, `description`, `keywords`) only land in the
* output map when the caller actually supplied them; their absence is a
* "leave the existing value alone" signal, not a "clear" signal.
* `authors` and `security` are required and always present.
*/
function normaliseInput(
input: PackageUpdateInput,
): Partial<Record<keyof PackageUpdateInput, unknown>> {
const out: Partial<Record<keyof PackageUpdateInput, unknown>> = {};
out.license = input.license;
out.authors = input.authors.map((a) =>
omitUndefined({ name: a.name, url: a.url, email: a.email }),
);
out.security = input.security.map((c) => omitUndefined({ url: c.url, email: c.email }));
if (input.name !== undefined) out.name = input.name;
if (input.description !== undefined) out.description = input.description;
if (input.keywords !== undefined && input.keywords.length > 0) out.keywords = input.keywords;
if (input.sections !== undefined && Object.keys(input.sections).length > 0) {
out.sections = input.sections;
}
return out;
}
function omitUndefined<T extends Record<string, unknown>>(value: T): Partial<T> {
const out: Partial<T> = {};
for (const [k, v] of Object.entries(value)) {
if (v !== undefined) (out as Record<string, unknown>)[k] = v;
}
return out;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function defaultNow(): Date {
return new Date();
}
/**
* Structural equality for JSON-shaped values. Matches the contract we
* need for diffing: two values are equal iff their JSON serialisations
* (with sorted keys) would be byte-identical. Sufficient for the small,
* statically-typed values we diff here; not a general deep-equal.
*/
function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a === null || b === null) return false;
if (typeof a !== typeof b) return false;
if (Array.isArray(a)) {
if (!Array.isArray(b) || a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!deepEqual(a[i], b[i])) return false;
}
return true;
}
if (isPlainObject(a)) {
if (!isPlainObject(b)) return false;
const ak = Object.keys(a);
const bk = Object.keys(b);
if (ak.length !== bk.length) return false;
for (const k of ak) {
if (!Object.hasOwn(b, k)) return false;
if (!deepEqual(a[k], b[k])) return false;
}
return true;
}
return false;
}
async function fetchExistingProfile(
publisher: PublishingClient,
slug: string,
): Promise<{ uri: string; cid: string; value: unknown } | null> {
try {
return await publisher.getRecord({ collection: NSID.packageProfile, rkey: slug });
} catch (error) {
if (error instanceof ClientResponseError && error.error === "RecordNotFound") {
return null;
}
throw error;
}
}
/** Max number of sibling slugs to list in the POSSIBLE_RENAME diagnostic. */
const POSSIBLE_RENAME_MAX_SIBLINGS = 10;
/**
* When no package is found at the requested slug, scan the publisher's
* packageProfile collection for any other packages so we can warn that a
* manifest rename would orphan them. Returns the slugs in document order,
* capped at {@link POSSIBLE_RENAME_MAX_SIBLINGS}.
*
* Auth/permission failures are RE-THROWN so the user sees the real cause
* (e.g. "re-login") rather than a misleading `PACKAGE_NOT_FOUND` that
* never actually ran the rename check. Transient network errors are
* swallowed — the rename diagnostic is best-effort and a retry will hit
* the same path anyway.
*/
async function findSiblingPackageSlugs(
publisher: PublishingClient,
missingSlug: string,
): Promise<string[]> {
let page: Awaited<ReturnType<PublishingClient["listRecords"]>>;
try {
page = await publisher.listRecords({ collection: NSID.packageProfile, limit: 100 });
} catch (error) {
if (error instanceof ClientResponseError && isAuthFailure(error.error)) {
// Surface auth/permission errors so the caller sees the real
// cause instead of a misleading PACKAGE_NOT_FOUND from the
// rename-check having silently no-op'd.
throw error;
}
// Transient network / PDS-down / unknown — degrade to "no
// siblings" and let PACKAGE_NOT_FOUND fire. A retry will hit
// the same path.
return [];
}
const siblings: string[] = [];
for (const record of page.records) {
const rkey = atUriRkey(record.uri);
if (rkey && rkey !== missingSlug) siblings.push(rkey);
if (siblings.length >= POSSIBLE_RENAME_MAX_SIBLINGS) break;
}
return siblings;
}
/**
* atproto error codes that indicate the session can't authenticate
* against the PDS. Surfaced rather than swallowed so failure messages
* point the user at re-login rather than the wrong diagnostic.
*/
function isAuthFailure(code: string): boolean {
return (
code === "AuthenticationRequired" ||
code === "AuthRequired" ||
code === "InvalidToken" ||
code === "ExpiredToken" ||
code === "AccountTakedown" ||
code === "Forbidden"
);
}
/** Extract the rkey from an `at://did/nsid/rkey` URI. Returns null on bad shape. */
function atUriRkey(uri: string): string | null {
const trailing = uri.split("/").pop();
return trailing && trailing.length > 0 ? trailing : null;
}
/**
* Validate caller input against the lexicon's structural rules that the
* manifest schema also enforces. The CLI never reaches here with invalid
* input (the manifest schema is the first gate), but the api is exported
* and programmatic callers can submit arrays that the lexicon rejects.
* Returning a clear `INVALID_INPUT` is friendlier than letting the failure
* cascade to `LEXICON_VALIDATION_FAILED` on the candidate.
*
* Returns an error message on failure, or `null` when the input is OK.
*/
function validateInput(input: PackageUpdateInput): string | null {
if (typeof input.license !== "string" || input.license.length === 0) {
return "license must be a non-empty SPDX expression.";
}
if (!Array.isArray(input.authors) || input.authors.length === 0) {
return "authors must be a non-empty array (lexicon requires at least one author).";
}
for (const [i, author] of input.authors.entries()) {
if (!author || typeof author.name !== "string" || author.name.length === 0) {
return `authors[${i}].name must be a non-empty string.`;
}
}
if (!Array.isArray(input.security) || input.security.length === 0) {
return "security must be a non-empty array (lexicon requires at least one security contact).";
}
for (const [i, contact] of input.security.entries()) {
if (!contact || (!contact.url && !contact.email)) {
return `security[${i}] must have at least one of \`url\` or \`email\`.`;
}
}
return null;
}