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,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());
|
||||
}
|
||||
Reference in New Issue
Block a user