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:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View 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
`;

View 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);
});

View 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("-"));
}