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
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:
@@ -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";
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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)}`);
|
||||
},
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)`)}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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})`)}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>")}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("/", "/");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 1–32 authors and
|
||||
* 1–8 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;
|
||||
}
|
||||
Reference in New Issue
Block a user