Emdash source with visual editor image upload fix
Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
273
packages/create-emdash/src/flags.ts
Normal file
273
packages/create-emdash/src/flags.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
import { PROJECT_NAME_PATTERN } from "./utils.js";
|
||||
|
||||
/**
|
||||
* Flag-driven configuration for non-interactive scaffolding.
|
||||
*
|
||||
* Every field is optional — `main()` only skips the corresponding prompt when
|
||||
* the field is set, so partial flag use still drops the user into prompts for
|
||||
* the remaining choices. With `--yes`, missing fields fall back to sensible
|
||||
* defaults (or to `"my-site"` for the project name).
|
||||
*/
|
||||
export interface ParsedFlags {
|
||||
/** Positional project name (or "."). Validated against PROJECT_NAME_PATTERN. */
|
||||
name?: string;
|
||||
platform?: Platform;
|
||||
template?: TemplateKey;
|
||||
packageManager?: PackageManager;
|
||||
/** `--install` / `--no-install`. Undefined means "ask". */
|
||||
install?: boolean;
|
||||
/** `--yes` — auto-accept remaining defaults and skip overwrite prompts. */
|
||||
yes: boolean;
|
||||
/**
|
||||
* `--force` — proceed when the target directory is non-empty. Required
|
||||
* to overwrite a non-empty target under `--yes`; otherwise we refuse to
|
||||
* silently clobber files.
|
||||
*/
|
||||
force: boolean;
|
||||
/** `--help` — print usage and exit. */
|
||||
help: boolean;
|
||||
}
|
||||
|
||||
export type Platform = "node" | "cloudflare";
|
||||
export type TemplateKey = "blog" | "starter" | "marketing" | "portfolio";
|
||||
export type PackageManager = "pnpm" | "npm" | "yarn" | "bun";
|
||||
|
||||
const PLATFORMS: readonly Platform[] = ["node", "cloudflare"] as const;
|
||||
const TEMPLATES: readonly TemplateKey[] = ["blog", "starter", "marketing", "portfolio"] as const;
|
||||
const PACKAGE_MANAGERS: readonly PackageManager[] = ["pnpm", "npm", "yarn", "bun"] as const;
|
||||
|
||||
/**
|
||||
* Thrown by `parseFlags()` for malformed input. `main()` catches and prints a
|
||||
* red error line plus the help text, then exits non-zero.
|
||||
*/
|
||||
export class FlagError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "FlagError";
|
||||
}
|
||||
}
|
||||
|
||||
function isPlatform(value: string): value is Platform {
|
||||
return (PLATFORMS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function isTemplate(value: string): value is TemplateKey {
|
||||
return (TEMPLATES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function isPackageManager(value: string): value is PackageManager {
|
||||
return (PACKAGE_MANAGERS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick scan for `--help` / `-h` in raw argv, used to short-circuit help
|
||||
* before strict argument parsing runs. Without this, a user who types
|
||||
* `npm create emdash@latest --help --template nope` gets the parse error
|
||||
* for the bad template instead of the help they asked for.
|
||||
*/
|
||||
export function wantsHelp(argv: string[]): boolean {
|
||||
return argv.slice(2).some((arg) => arg === "--help" || arg === "-h");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `process.argv`-style array into a {@link ParsedFlags}.
|
||||
*
|
||||
* Accepted forms (lifted from established `create-*` tools):
|
||||
* - Positional: `[name]` — the project directory (or `.` for cwd).
|
||||
* - `--template <key>` — one of `blog | starter | marketing | portfolio`,
|
||||
* or the combined form `<platform>:<template>` (e.g. `cloudflare:blog`).
|
||||
* - `--platform <node | cloudflare>`.
|
||||
* - `--pm <pnpm | npm | yarn | bun>` (alias: `--package-manager`).
|
||||
* - `--install` / `--no-install` — toggle dependency install.
|
||||
* - `--yes`, `-y` — accept defaults; skip overwrite confirmations (with --force).
|
||||
* - `--force` — required to overwrite a non-empty target under `--yes`.
|
||||
* - `--help`, `-h`.
|
||||
*
|
||||
* Throws {@link FlagError} on unknown flags, invalid values, or unexpected
|
||||
* extra positionals. `parseArgs` itself also throws on malformed input
|
||||
* (missing values, unknown options) — those errors bubble up unchanged so the
|
||||
* caller can surface them with their own framing.
|
||||
*/
|
||||
export function parseFlags(argv: string[]): ParsedFlags {
|
||||
const { values, positionals } = parseArgs({
|
||||
args: argv.slice(2),
|
||||
options: {
|
||||
template: { type: "string" },
|
||||
platform: { type: "string" },
|
||||
pm: { type: "string" },
|
||||
"package-manager": { type: "string" },
|
||||
install: { type: "boolean" },
|
||||
"no-install": { type: "boolean" },
|
||||
yes: { type: "boolean", short: "y" },
|
||||
force: { type: "boolean" },
|
||||
help: { type: "boolean", short: "h" },
|
||||
},
|
||||
allowPositionals: true,
|
||||
// `strict: true` (default) makes parseArgs throw on unknown flags,
|
||||
// which we want — typos like `--temaplte` should fail loudly, not
|
||||
// silently fall through to interactive mode.
|
||||
});
|
||||
|
||||
const flags: ParsedFlags = {
|
||||
yes: values.yes === true,
|
||||
force: values.force === true,
|
||||
help: values.help === true,
|
||||
};
|
||||
|
||||
// Positional name: first non-flag arg. We accept "." for "scaffold into
|
||||
// cwd" — index.ts handles that branch directly. Extra positionals are
|
||||
// rejected because they're almost always typos: `npm create emdash my blog`
|
||||
// (space instead of hyphen) parses cleanly otherwise and silently uses
|
||||
// "my" as the project name, which is hostile.
|
||||
if (positionals.length > 1) {
|
||||
throw new FlagError(
|
||||
`Unexpected extra argument "${positionals[1]}". Did you mean a hyphen instead of a space in the project name?`,
|
||||
);
|
||||
}
|
||||
const positional = positionals[0];
|
||||
if (positional !== undefined) {
|
||||
// Defer name validation to the resolver so the prompt path can keep
|
||||
// its existing user-friendly error message. parseFlags only stores
|
||||
// the raw value and lets resolveProjectLocation enforce the pattern
|
||||
// at the same place the prompt's validate() runs.
|
||||
flags.name = positional;
|
||||
}
|
||||
|
||||
// We collect platform from --platform *and* from the combined --template
|
||||
// form, then reconcile at the end so the conflict check is independent
|
||||
// of argv order. Tracking the *source* of each value is what makes the
|
||||
// error message useful.
|
||||
let platformFromFlag: Platform | undefined;
|
||||
let platformFromTemplate: Platform | undefined;
|
||||
|
||||
// Platform: --platform <node | cloudflare>
|
||||
if (values.platform !== undefined) {
|
||||
if (!isPlatform(values.platform)) {
|
||||
throw new FlagError(
|
||||
`--platform must be one of ${PLATFORMS.join(", ")} (got "${values.platform}").`,
|
||||
);
|
||||
}
|
||||
platformFromFlag = values.platform;
|
||||
}
|
||||
|
||||
// Template: --template <key> or --template <platform>:<key>.
|
||||
// The combined form is convenient for one-shot installs and matches the
|
||||
// shape suggested in the issue. We split on `:` and apply both halves.
|
||||
if (values.template !== undefined) {
|
||||
const raw = values.template;
|
||||
const colon = raw.indexOf(":");
|
||||
if (colon !== -1) {
|
||||
const platformPart = raw.slice(0, colon);
|
||||
const templatePart = raw.slice(colon + 1);
|
||||
if (platformPart === "" || templatePart === "") {
|
||||
throw new FlagError(`--template must be "<key>" or "<platform>:<key>" (got "${raw}").`);
|
||||
}
|
||||
if (!isPlatform(platformPart)) {
|
||||
throw new FlagError(
|
||||
`--template platform prefix must be one of ${PLATFORMS.join(", ")} (got "${platformPart}").`,
|
||||
);
|
||||
}
|
||||
if (!isTemplate(templatePart)) {
|
||||
throw new FlagError(
|
||||
`--template name must be one of ${TEMPLATES.join(", ")} (got "${templatePart}").`,
|
||||
);
|
||||
}
|
||||
platformFromTemplate = platformPart;
|
||||
flags.template = templatePart;
|
||||
} else {
|
||||
if (!isTemplate(raw)) {
|
||||
throw new FlagError(`--template must be one of ${TEMPLATES.join(", ")} (got "${raw}").`);
|
||||
}
|
||||
flags.template = raw;
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile platform sources. If both are set and disagree, error out
|
||||
// regardless of argv order — easy footgun if a user copies a flag set
|
||||
// and edits one half.
|
||||
if (platformFromFlag !== undefined && platformFromTemplate !== undefined) {
|
||||
if (platformFromFlag !== platformFromTemplate) {
|
||||
throw new FlagError(
|
||||
`--platform "${platformFromFlag}" conflicts with --template "${values.template}". Pass one or the other.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
flags.platform = platformFromFlag ?? platformFromTemplate;
|
||||
|
||||
// Package manager: --pm or --package-manager (the latter for parity with
|
||||
// other ecosystem CLIs). If both are given they must agree — otherwise
|
||||
// it's almost certainly a typo on the user's end.
|
||||
const pmRaw = values.pm ?? values["package-manager"];
|
||||
if (values.pm !== undefined && values["package-manager"] !== undefined) {
|
||||
if (values.pm !== values["package-manager"]) {
|
||||
throw new FlagError(
|
||||
`--pm "${values.pm}" conflicts with --package-manager "${values["package-manager"]}". Pass one or the other.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (pmRaw !== undefined) {
|
||||
if (!isPackageManager(pmRaw)) {
|
||||
throw new FlagError(`--pm must be one of ${PACKAGE_MANAGERS.join(", ")} (got "${pmRaw}").`);
|
||||
}
|
||||
flags.packageManager = pmRaw;
|
||||
}
|
||||
|
||||
// Install: --install / --no-install. parseArgs surfaces both as separate
|
||||
// boolean keys; conflicting values are user error.
|
||||
if (values.install === true && values["no-install"] === true) {
|
||||
throw new FlagError(`--install and --no-install cannot both be set.`);
|
||||
}
|
||||
if (values.install === true) flags.install = true;
|
||||
if (values["no-install"] === true) flags.install = false;
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a positional name (after `parseFlags` returns it). Kept separate
|
||||
* so the parser stays purely structural and the resolver owns the
|
||||
* "what counts as a valid project name" rule. Mirrors the prompt's validator.
|
||||
*/
|
||||
export function validateProjectName(name: string): string | undefined {
|
||||
if (name === ".") return undefined;
|
||||
if (!PROJECT_NAME_PATTERN.test(name)) {
|
||||
return "Project name can only contain lowercase letters, numbers, and hyphens";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Help text printed for `--help` / `-h`. Kept in sync with {@link parseFlags}.
|
||||
*/
|
||||
export const HELP_TEXT = `Usage: npm create emdash@latest [name] [options]
|
||||
|
||||
Scaffold a new EmDash project.
|
||||
|
||||
When a flag is omitted, an interactive prompt is shown for that field.
|
||||
With --yes, omitted fields fall back to defaults (cloudflare, blog, the
|
||||
detected package manager, my-site for an unset name).
|
||||
|
||||
Arguments:
|
||||
[name] Project directory name, or "." for cwd
|
||||
|
||||
Options:
|
||||
--template <key> blog | starter | marketing | portfolio
|
||||
or "<platform>:<key>" (e.g. cloudflare:blog)
|
||||
--platform <key> node | cloudflare
|
||||
--pm <key> pnpm | npm | yarn | bun
|
||||
--package-manager <key> Alias of --pm
|
||||
--install Install dependencies after scaffolding
|
||||
--no-install Skip dependency install
|
||||
-y, --yes Accept defaults; skip confirmation prompts
|
||||
--force Allow overwriting a non-empty target dir
|
||||
(required with --yes when the target is non-empty)
|
||||
-h, --help Show this help text
|
||||
|
||||
Examples:
|
||||
npm create emdash@latest
|
||||
npm create emdash@latest my-blog
|
||||
npm create emdash@latest my-blog -- --template cloudflare:blog --pm pnpm --yes
|
||||
npm create emdash@latest . -- --template starter --platform node --yes --force
|
||||
`;
|
||||
435
packages/create-emdash/src/index.ts
Normal file
435
packages/create-emdash/src/index.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* create-emdash
|
||||
*
|
||||
* CLI for creating new EmDash projects.
|
||||
*
|
||||
* Defaults to an interactive flow. Pass flags (or --yes) to run
|
||||
* non-interactively — see `--help` or {@link HELP_TEXT} for the full set.
|
||||
*
|
||||
* Usage: npm create emdash@latest [name] [options]
|
||||
*/
|
||||
|
||||
import { exec } from "node:child_process";
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
import * as p from "@clack/prompts";
|
||||
import { downloadTemplate } from "giget";
|
||||
import pc from "picocolors";
|
||||
|
||||
import {
|
||||
FlagError,
|
||||
HELP_TEXT,
|
||||
type PackageManager,
|
||||
type ParsedFlags,
|
||||
type Platform,
|
||||
type TemplateKey,
|
||||
parseFlags,
|
||||
validateProjectName,
|
||||
wantsHelp,
|
||||
} from "./flags.js";
|
||||
import {
|
||||
PROJECT_NAME_PATTERN,
|
||||
isDirNonEmpty,
|
||||
sanitizePackageName,
|
||||
writeEncryptionKey,
|
||||
} from "./utils.js";
|
||||
|
||||
const GITHUB_REPO = "emdash-cms/templates";
|
||||
|
||||
interface TemplateConfig {
|
||||
name: string;
|
||||
description: string;
|
||||
/** Directory name in the templates repo */
|
||||
dir: string;
|
||||
}
|
||||
|
||||
const NODE_TEMPLATES = {
|
||||
blog: {
|
||||
name: "Blog",
|
||||
description: "A blog with posts, pages, and authors",
|
||||
dir: "blog",
|
||||
},
|
||||
starter: {
|
||||
name: "Starter",
|
||||
description: "A general-purpose starter with posts and pages",
|
||||
dir: "starter",
|
||||
},
|
||||
marketing: {
|
||||
name: "Marketing",
|
||||
description: "A marketing site with landing pages and CTAs",
|
||||
dir: "marketing",
|
||||
},
|
||||
portfolio: {
|
||||
name: "Portfolio",
|
||||
description: "A portfolio site with projects and case studies",
|
||||
dir: "portfolio",
|
||||
},
|
||||
} as const satisfies Record<TemplateKey, TemplateConfig>;
|
||||
|
||||
const CLOUDFLARE_TEMPLATES = {
|
||||
blog: {
|
||||
name: "Blog",
|
||||
description: "A blog with posts, pages, and authors",
|
||||
dir: "blog-cloudflare",
|
||||
},
|
||||
starter: {
|
||||
name: "Starter",
|
||||
description: "A general-purpose starter with posts and pages",
|
||||
dir: "starter-cloudflare",
|
||||
},
|
||||
marketing: {
|
||||
name: "Marketing",
|
||||
description: "A marketing site with landing pages and CTAs",
|
||||
dir: "marketing-cloudflare",
|
||||
},
|
||||
portfolio: {
|
||||
name: "Portfolio",
|
||||
description: "A portfolio site with projects and case studies",
|
||||
dir: "portfolio-cloudflare",
|
||||
},
|
||||
} as const satisfies Record<TemplateKey, TemplateConfig>;
|
||||
|
||||
/** Defaults applied under `--yes` when the user omits a flag. */
|
||||
const DEFAULT_PLATFORM: Platform = "cloudflare";
|
||||
const DEFAULT_TEMPLATE: TemplateKey = "blog";
|
||||
/** Used by `--yes` when the user omits the project name positional. */
|
||||
const DEFAULT_PROJECT_NAME = "my-site";
|
||||
|
||||
/** Detect which package manager invoked us, or fall back to npm */
|
||||
function detectPackageManager(): PackageManager {
|
||||
const agent = process.env.npm_config_user_agent ?? "";
|
||||
if (agent.startsWith("pnpm")) return "pnpm";
|
||||
if (agent.startsWith("yarn")) return "yarn";
|
||||
if (agent.startsWith("bun")) return "bun";
|
||||
return "npm";
|
||||
}
|
||||
|
||||
/** Build select options from a config object, preserving literal key types */
|
||||
function selectOptions<K extends string>(
|
||||
obj: Readonly<Record<K, Readonly<{ name: string; description: string }>>>,
|
||||
): { value: K; label: string; hint: string }[] {
|
||||
const keys: K[] = Object.keys(obj).filter((k): k is K => k in obj);
|
||||
return keys.map((key) => ({
|
||||
value: key,
|
||||
label: obj[key].name,
|
||||
hint: obj[key].description,
|
||||
}));
|
||||
}
|
||||
|
||||
const NEWLINE_PATTERN = /\r?\n/;
|
||||
|
||||
/**
|
||||
* Make sure `fileName` is excluded by `.gitignore`. Templates' gitignores
|
||||
* already cover `.env*` but not `.dev.vars`; rather than relying on every
|
||||
* template being current, the scaffolder defensively appends a stanza if
|
||||
* no existing line matches.
|
||||
*/
|
||||
function ensureGitignored(projectDir: string, fileName: string): void {
|
||||
const target = resolve(projectDir, ".gitignore");
|
||||
const existing = existsSync(target) ? readFileSync(target, "utf-8") : "";
|
||||
const lines = existing.split(NEWLINE_PATTERN);
|
||||
if (lines.some((line) => line.trim() === fileName)) {
|
||||
return;
|
||||
}
|
||||
const sep = existing.length === 0 ? "" : existing.endsWith("\n") ? "" : "\n";
|
||||
const next = `${existing}${sep}${fileName}\n`;
|
||||
writeFileSync(target, next);
|
||||
}
|
||||
|
||||
function getTemplateConfig(platform: Platform, key: TemplateKey): TemplateConfig {
|
||||
return platform === "node" ? NODE_TEMPLATES[key] : CLOUDFLARE_TEMPLATES[key];
|
||||
}
|
||||
|
||||
async function selectTemplate(platform: Platform): Promise<TemplateKey> {
|
||||
const map = platform === "node" ? NODE_TEMPLATES : CLOUDFLARE_TEMPLATES;
|
||||
const key = await p.select<TemplateKey>({
|
||||
message: "Which template?",
|
||||
options: selectOptions(map),
|
||||
initialValue: "blog",
|
||||
});
|
||||
if (p.isCancel(key)) {
|
||||
p.cancel("Operation cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the project name + directory. Honours flags first, then falls back
|
||||
* to the interactive prompt.
|
||||
*
|
||||
* Returns `null` if the user declined to overwrite a non-empty target.
|
||||
*/
|
||||
async function resolveProjectLocation(
|
||||
flags: ParsedFlags,
|
||||
): Promise<{ projectName: string; projectDir: string; isCurrentDir: boolean } | null> {
|
||||
// Validate the positional once, here, so the error message matches the
|
||||
// prompt path and parseFlags stays purely structural. parseFlags already
|
||||
// captured the raw value; this is the only place that enforces the
|
||||
// pattern across both flag and prompt entry points.
|
||||
if (flags.name !== undefined) {
|
||||
const error = validateProjectName(flags.name);
|
||||
if (error) {
|
||||
p.cancel(`${error}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const target = flags.name;
|
||||
const isCurrentDir = target === ".";
|
||||
|
||||
if (isCurrentDir) {
|
||||
const projectDir = process.cwd();
|
||||
const projectName = sanitizePackageName(basename(projectDir));
|
||||
if (isDirNonEmpty(projectDir)) {
|
||||
// Under --yes we refuse to clobber unless --force is also set.
|
||||
// Silently overwriting source files in the user's cwd is the
|
||||
// kind of default we should never ship.
|
||||
if (flags.yes && !flags.force) {
|
||||
p.cancel("Current directory is not empty. Re-run with --force to allow overwriting.");
|
||||
process.exit(1);
|
||||
}
|
||||
if (!flags.yes) {
|
||||
const proceed = await p.confirm({
|
||||
message: "Current directory is not empty. Files may be overwritten. Continue?",
|
||||
initialValue: false,
|
||||
});
|
||||
if (p.isCancel(proceed) || !proceed) return null;
|
||||
}
|
||||
// flags.yes && flags.force: proceed silently.
|
||||
}
|
||||
return { projectName, projectDir, isCurrentDir: true };
|
||||
}
|
||||
|
||||
let projectName: string;
|
||||
if (target !== undefined) {
|
||||
projectName = target;
|
||||
} else if (flags.yes) {
|
||||
// --yes with no positional: fall back to the documented default
|
||||
// rather than silently dropping into the (broken-in-non-TTY) prompt.
|
||||
// This is the contract documented in flags.ts and HELP_TEXT.
|
||||
projectName = DEFAULT_PROJECT_NAME;
|
||||
} else {
|
||||
const name = await p.text({
|
||||
message: "Project name?",
|
||||
placeholder: DEFAULT_PROJECT_NAME,
|
||||
defaultValue: DEFAULT_PROJECT_NAME,
|
||||
validate: (value) => {
|
||||
if (!value) return "Project name is required";
|
||||
if (!PROJECT_NAME_PATTERN.test(value))
|
||||
return "Project name can only contain lowercase letters, numbers, and hyphens";
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
if (p.isCancel(name)) return null;
|
||||
projectName = name;
|
||||
}
|
||||
|
||||
const projectDir = resolve(process.cwd(), projectName);
|
||||
if (isDirNonEmpty(projectDir)) {
|
||||
if (flags.yes && !flags.force) {
|
||||
p.cancel(
|
||||
`Directory ${projectName} already exists and is not empty. Re-run with --force to allow overwriting.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!flags.yes) {
|
||||
const overwrite = await p.confirm({
|
||||
message: `Directory ${projectName} already exists and is not empty. Files may be overwritten. Continue?`,
|
||||
initialValue: false,
|
||||
});
|
||||
if (p.isCancel(overwrite) || !overwrite) return null;
|
||||
}
|
||||
// flags.yes && flags.force: proceed silently.
|
||||
}
|
||||
return { projectName, projectDir, isCurrentDir: false };
|
||||
}
|
||||
|
||||
async function resolvePlatform(flags: ParsedFlags): Promise<Platform> {
|
||||
if (flags.platform !== undefined) return flags.platform;
|
||||
if (flags.yes) return DEFAULT_PLATFORM;
|
||||
const platform = await p.select<Platform>({
|
||||
message: "Where will you deploy?",
|
||||
options: [
|
||||
{ value: "cloudflare", label: "Cloudflare Workers", hint: "D1 + R2" },
|
||||
{ value: "node", label: "Node.js", hint: "SQLite + local file storage" },
|
||||
],
|
||||
initialValue: "cloudflare",
|
||||
});
|
||||
if (p.isCancel(platform)) {
|
||||
p.cancel("Operation cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
return platform;
|
||||
}
|
||||
|
||||
async function resolveTemplate(flags: ParsedFlags, platform: Platform): Promise<TemplateKey> {
|
||||
if (flags.template !== undefined) return flags.template;
|
||||
if (flags.yes) return DEFAULT_TEMPLATE;
|
||||
return selectTemplate(platform);
|
||||
}
|
||||
|
||||
async function resolvePackageManager(flags: ParsedFlags): Promise<PackageManager> {
|
||||
if (flags.packageManager !== undefined) return flags.packageManager;
|
||||
const detected = detectPackageManager();
|
||||
if (flags.yes) return detected;
|
||||
const pm = await p.select<PackageManager>({
|
||||
message: "Which package manager?",
|
||||
options: [
|
||||
{ value: "pnpm", label: "pnpm" },
|
||||
{ value: "npm", label: "npm" },
|
||||
{ value: "yarn", label: "yarn" },
|
||||
{ value: "bun", label: "bun" },
|
||||
],
|
||||
initialValue: detected,
|
||||
});
|
||||
if (p.isCancel(pm)) {
|
||||
p.cancel("Operation cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
return pm;
|
||||
}
|
||||
|
||||
async function resolveShouldInstall(flags: ParsedFlags): Promise<boolean> {
|
||||
if (flags.install !== undefined) return flags.install;
|
||||
if (flags.yes) return true;
|
||||
const shouldInstall = await p.confirm({
|
||||
message: "Install dependencies?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (p.isCancel(shouldInstall)) {
|
||||
p.cancel("Operation cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
return shouldInstall;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Short-circuit --help before strict parsing so a user typing
|
||||
// `npm create emdash@latest --help --template nope` gets the help they
|
||||
// asked for, not the parse error for the bad template.
|
||||
if (wantsHelp(process.argv)) {
|
||||
console.log(HELP_TEXT);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let flags: ParsedFlags;
|
||||
try {
|
||||
flags = parseFlags(process.argv);
|
||||
} catch (error) {
|
||||
// FlagError carries a friendly message; parseArgs's own errors do too
|
||||
// (e.g. "Unknown option '--templat'"). Either way, surface and exit.
|
||||
const message =
|
||||
error instanceof FlagError || error instanceof Error ? error.message : String(error);
|
||||
console.error(`\n${pc.red("Error:")} ${message}\n`);
|
||||
console.error(HELP_TEXT);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.clear();
|
||||
console.log(`\n ${pc.bold(pc.cyan("— E M D A S H —"))}\n`);
|
||||
p.intro("Create a new EmDash project");
|
||||
|
||||
const location = await resolveProjectLocation(flags);
|
||||
if (location === null) {
|
||||
p.cancel("Operation cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
const { projectName, projectDir, isCurrentDir } = location;
|
||||
|
||||
const platform = await resolvePlatform(flags);
|
||||
const templateKey = await resolveTemplate(flags, platform);
|
||||
const templateConfig = getTemplateConfig(platform, templateKey);
|
||||
const pm = await resolvePackageManager(flags);
|
||||
const shouldInstall = await resolveShouldInstall(flags);
|
||||
|
||||
const installCmd = `${pm} install`;
|
||||
const runCmd = (script: string) => (pm === "npm" ? `npm run ${script}` : `${pm} ${script}`);
|
||||
|
||||
const s = p.spinner();
|
||||
s.start("Creating project...");
|
||||
|
||||
try {
|
||||
await downloadTemplate(`github:${GITHUB_REPO}/${templateConfig.dir}`, {
|
||||
dir: projectDir,
|
||||
force: true,
|
||||
});
|
||||
|
||||
// Set project name in package.json
|
||||
const pkgPath = resolve(projectDir, "package.json");
|
||||
if (existsSync(pkgPath)) {
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
||||
pkg.name = projectName;
|
||||
|
||||
// Add emdash config if template has seed data
|
||||
const seedPath = resolve(projectDir, "seed", "seed.json");
|
||||
if (existsSync(seedPath)) {
|
||||
pkg.emdash = {
|
||||
label: templateConfig.name,
|
||||
seed: "seed/seed.json",
|
||||
};
|
||||
}
|
||||
|
||||
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));
|
||||
}
|
||||
|
||||
// Scaffold a fresh EMDASH_ENCRYPTION_KEY into the local-secrets file
|
||||
// (Workers: .dev.vars, Node: .env). Idempotent — won't overwrite an
|
||||
// existing entry if the user re-runs scaffolding into a non-empty
|
||||
// directory. We also defensively ensure the file is gitignored.
|
||||
const secretsFile = platform === "cloudflare" ? ".dev.vars" : ".env";
|
||||
const keyResult = writeEncryptionKey(projectDir, secretsFile);
|
||||
ensureGitignored(projectDir, secretsFile);
|
||||
|
||||
s.stop("Project created!");
|
||||
|
||||
if (keyResult === "skipped") {
|
||||
p.log.info(
|
||||
`Existing ${pc.cyan("EMDASH_ENCRYPTION_KEY")} found in ${pc.cyan(secretsFile)}; leaving it alone.`,
|
||||
);
|
||||
} else {
|
||||
p.log.info(`Wrote ${pc.cyan("EMDASH_ENCRYPTION_KEY")} to ${pc.cyan(secretsFile)}.`);
|
||||
}
|
||||
|
||||
if (shouldInstall) {
|
||||
s.start(`Installing dependencies with ${pc.cyan(pm)}...`);
|
||||
try {
|
||||
await execAsync(installCmd, { cwd: projectDir });
|
||||
s.stop("Dependencies installed!");
|
||||
} catch {
|
||||
s.stop("Failed to install dependencies");
|
||||
p.log.warn(
|
||||
isCurrentDir
|
||||
? `Run ${pc.cyan(installCmd)} manually`
|
||||
: `Run ${pc.cyan(`cd ${projectName} && ${installCmd}`)} manually`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const steps: string[] = [];
|
||||
if (!isCurrentDir) steps.push(`cd ${projectName}`);
|
||||
if (!shouldInstall) steps.push(installCmd);
|
||||
steps.push(runCmd("dev"));
|
||||
|
||||
p.note(steps.join("\n"), "Next steps");
|
||||
|
||||
p.outro(
|
||||
isCurrentDir
|
||||
? `${pc.green("Done!")} Your EmDash project is ready in the current directory`
|
||||
: `${pc.green("Done!")} Your EmDash project is ready at ${pc.cyan(projectName)}`,
|
||||
);
|
||||
} catch (error) {
|
||||
s.stop("Failed to create project");
|
||||
p.log.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
87
packages/create-emdash/src/utils.ts
Normal file
87
packages/create-emdash/src/utils.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export const PROJECT_NAME_PATTERN = /^[a-z0-9-]+$/;
|
||||
const INVALID_PKG_NAME_CHARS = /[^a-z0-9-]/g;
|
||||
const LEADING_TRAILING_HYPHENS = /^-+|-+$/g;
|
||||
|
||||
/**
|
||||
* Generate a fresh `EMDASH_ENCRYPTION_KEY` value.
|
||||
*
|
||||
* Format mirrors `packages/core/src/config/secrets.ts` (`emdash_enc_v1_`
|
||||
* followed by 32 random bytes encoded as unpadded base64url, 43 chars).
|
||||
*
|
||||
* Vendored here rather than imported from `emdash` so create-emdash stays
|
||||
* a small standalone package — the core package is not yet installed at
|
||||
* scaffold time.
|
||||
*/
|
||||
export function generateEncryptionKey(): string {
|
||||
const body = randomBytes(32).toString("base64url");
|
||||
return `emdash_enc_v1_${body}`;
|
||||
}
|
||||
|
||||
/** Matches a populated entry — `KEY=<at least one char>`. */
|
||||
const POPULATED_KEY_LINE_PATTERN = /^EMDASH_ENCRYPTION_KEY=.+$/m;
|
||||
/** Matches any entry (including `KEY=` empty value), for in-place replace. */
|
||||
const ANY_KEY_LINE_PATTERN = /^EMDASH_ENCRYPTION_KEY=.*$/m;
|
||||
|
||||
/**
|
||||
* Write `EMDASH_ENCRYPTION_KEY=...` into a dotenv-style local-secrets file
|
||||
* (`.dev.vars` for Workers, `.env` for Node).
|
||||
*
|
||||
* Idempotent: if the entry exists with a populated value, leaves it alone.
|
||||
* An entry with an empty value (`EMDASH_ENCRYPTION_KEY=`, e.g. a placeholder
|
||||
* copied from `.env.example`) is treated as not-set and gets replaced.
|
||||
*
|
||||
* Returns `"wrote"` if a new entry was added or an empty placeholder was
|
||||
* filled in, `"skipped"` if an existing populated entry was found.
|
||||
*
|
||||
* Mirrors `writeEncryptionKeyToFile` in `packages/core/src/cli/commands/secrets.ts`.
|
||||
* Vendored for the same reason as `generateEncryptionKey` — create-emdash
|
||||
* doesn't depend on the emdash core package.
|
||||
*/
|
||||
export function writeEncryptionKey(projectDir: string, fileName: string): "wrote" | "skipped" {
|
||||
const target = resolve(projectDir, fileName);
|
||||
const existing = existsSync(target) ? readFileSync(target, "utf-8") : "";
|
||||
if (POPULATED_KEY_LINE_PATTERN.test(existing)) {
|
||||
return "skipped";
|
||||
}
|
||||
const value = generateEncryptionKey();
|
||||
const newLine = `EMDASH_ENCRYPTION_KEY=${value}`;
|
||||
let next: string;
|
||||
if (ANY_KEY_LINE_PATTERN.test(existing)) {
|
||||
next = existing.replace(ANY_KEY_LINE_PATTERN, newLine);
|
||||
if (!next.endsWith("\n")) next += "\n";
|
||||
} else {
|
||||
const sep = existing.length === 0 ? "" : existing.endsWith("\n") ? "" : "\n";
|
||||
next = `${existing}${sep}${newLine}\n`;
|
||||
}
|
||||
writeFileSync(target, next);
|
||||
return "wrote";
|
||||
}
|
||||
|
||||
/** Sanitise a directory basename into a valid npm package name */
|
||||
export function sanitizePackageName(name: string): string {
|
||||
return (
|
||||
name.toLowerCase().replace(INVALID_PKG_NAME_CHARS, "-").replace(LEADING_TRAILING_HYPHENS, "") ||
|
||||
"my-site"
|
||||
);
|
||||
}
|
||||
|
||||
/** Check whether a directory exists and contains files */
|
||||
export function isDirNonEmpty(dir: string): boolean {
|
||||
try {
|
||||
return readdirSync(dir).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the first positional argument (not a flag) from an argv array.
|
||||
* Returns undefined if no positional argument is found.
|
||||
*/
|
||||
export function parseTargetArg(argv: string[]): string | undefined {
|
||||
return argv.slice(2).find((a) => !a.startsWith("-"));
|
||||
}
|
||||
Reference in New Issue
Block a user