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,93 @@
# create-emdash
## 0.9.0
### Minor Changes
- [#859](https://github.com/emdash-cms/emdash/pull/859) [`3015280`](https://github.com/emdash-cms/emdash/commit/301528075e1ca7b96589a6eed31a97d9cdfbb7f7) Thanks [@ask-bonk](https://github.com/apps/ask-bonk)! - Adds non-interactive mode to `create-emdash` for CI / scripted scaffolding (#711). Pass `--template`, `--platform`, `--pm`, `--install`/`--no-install`, `--yes`, and `--force` to skip prompts; partial flag use only prompts for unset fields. Interactive flow is unchanged when no flags are supplied.
- `--template <key>` accepts a bare template (`blog | starter | marketing | portfolio`) or the combined form `<platform>:<key>` (e.g. `cloudflare:blog`).
- `--pm <key>` (alias `--package-manager`) selects the package manager.
- `--yes` / `-y` accepts defaults for any unset field (cloudflare, blog, detected pm, `my-site` for an unset name).
- `--force` is required alongside `--yes` to overwrite a non-empty target directory; without it, the CLI refuses rather than silently clobbering files.
- `--help` / `-h` prints usage. Unknown flags fail loudly so typos don't silently drop into interactive mode.
- An extra positional argument (e.g. `npm create emdash my blog` with a space instead of a hyphen) is now rejected as a likely typo.
No new dependencies — built on `node:util`'s `parseArgs`.
- [#811](https://github.com/emdash-cms/emdash/pull/811) [`cee403d`](https://github.com/emdash-cms/emdash/commit/cee403d5c008feb9ca60bb7201e151b828737743) Thanks [@ascorbic](https://github.com/ascorbic)! - Scaffolds a fresh `EMDASH_ENCRYPTION_KEY` into `.dev.vars` (Cloudflare
templates) or `.env` (Node templates) on project creation, and ensures the
file is gitignored. Idempotent — won't overwrite an existing key on re-runs.
### Patch Changes
- [#852](https://github.com/emdash-cms/emdash/pull/852) [`e73bb5f`](https://github.com/emdash-cms/emdash/commit/e73bb5f3b54195ad6fdb327be79bddbbf25d0f17) Thanks [@ask-bonk](https://github.com/apps/ask-bonk)! - Removes the "Blank" template from the `npm create emdash` picker. The minimal-content template is `starter`; the previously listed `blank` only existed for the Node.js path (never Cloudflare) and was confusing. Pick `Starter` for a minimal site on either platform.
- [#869](https://github.com/emdash-cms/emdash/pull/869) [`a8bac5d`](https://github.com/emdash-cms/emdash/commit/a8bac5d7216e185b1bd9a2aaaeaa9a0306ab066e) Thanks [@ask-bonk](https://github.com/apps/ask-bonk)! - Fixes autosave validation errors on content seeded from the blog,
portfolio, and starter templates (issue #867).
Two related issues:
- `_key` was strictly required on Portable Text blocks by the
generated Zod schema, but the rest of the block schema is
`.passthrough()` and the editor regenerates `_key` on every change,
so requiring it on input rejected legitimate seed/import data
without protecting any real invariant. `_key` is now optional in the
validator.
- The portfolio template shipped `featured_image` as bare URL strings.
`image` fields validate as `{ id, ... }` objects, so any user who
edited a different field on a portfolio entry hit
`featured_image: expected object, received string`. The portfolio
seeds now use `$media` references in the same shape as the blog
template, and every shipped template seed has stable `_key`s on its
Portable Text nodes.
A regression test runs every shipped template seed through the same
validator the autosave endpoint uses, so future template changes that
break this invariant fail before release.
## 0.8.0
### Minor Changes
- [#785](https://github.com/emdash-cms/emdash/pull/785) [`e0dd616`](https://github.com/emdash-cms/emdash/commit/e0dd61680674d111814df9033e44d500b65c9562) Thanks [@MattieTK](https://github.com/MattieTK)! - Adds support for positional directory argument, allowing `npm create emdash .` to scaffold into the current directory and `npm create emdash my-project` to skip the interactive name prompt.
## 0.7.0
## 0.6.0
## 0.5.0
## 0.4.0
## 0.3.0
## 0.2.0
## 0.1.0
### Minor Changes
- [#14](https://github.com/emdash-cms/emdash/pull/14) [`755b501`](https://github.com/emdash-cms/emdash/commit/755b5017906811f97f78f4c0b5a0b62e67b52ec4) Thanks [@ascorbic](https://github.com/ascorbic)! - First beta release
### Patch Changes
- [#12](https://github.com/emdash-cms/emdash/pull/12) [`9db4c2c`](https://github.com/emdash-cms/emdash/commit/9db4c2cba24d5202fba630ac366ae42cf721390f) Thanks [@ascorbic](https://github.com/ascorbic)! - Remove manual bootstrap step from CLI output
The create-emdash CLI no longer suggests running `bootstrap` as a manual step, since EmDash now auto-bootstraps on first run.
## 0.0.4
### Patch Changes
- [#7](https://github.com/emdash-cms/emdash/pull/7) [`2022b77`](https://github.com/emdash-cms/emdash/commit/2022b773414a34de05677c776f4f4324f43a54e2) Thanks [@ascorbic](https://github.com/ascorbic)! - Fix spinner hanging during dependency installation by using async exec instead of execSync, which was blocking the event loop and preventing the spinner animation from updating.
## 0.0.3
### Patch Changes
- [#5](https://github.com/emdash-cms/emdash/pull/5) [`8e389d5`](https://github.com/emdash-cms/emdash/commit/8e389d5ef8b0a6b0577d9d7c975f048f96844185) Thanks [@ascorbic](https://github.com/ascorbic)! - Improve create-emdash CLI experience: add the EmDash branded banner, let users pick their package manager (auto-detects the one that invoked it), and ask whether to install dependencies with a spinner showing progress.
## 0.0.2
### Patch Changes
- [#3](https://github.com/emdash-cms/emdash/pull/3) [`2dc5815`](https://github.com/emdash-cms/emdash/commit/2dc5815f031459c48cfaffec84aea1ed7b9cf7fb) Thanks [@ascorbic](https://github.com/ascorbic)! - Fix create-emdash to use all available templates from the new standalone templates repo. Templates are now selected in two steps: platform (Node.js or Cloudflare Workers) then template type (blog, starter, marketing, portfolio, blank). Downloads from `emdash-cms/templates` instead of the old monorepo path.

View File

@@ -0,0 +1,40 @@
{
"name": "create-emdash",
"version": "0.9.0",
"description": "Create a new EmDash CMS project",
"type": "module",
"bin": "./dist/index.mjs",
"files": [
"dist"
],
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch",
"test": "vitest run",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@clack/prompts": "^0.10.0",
"giget": "^1.2.3",
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
},
"keywords": [
"create",
"emdash",
"astro",
"cms"
],
"author": "Matt Kane",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/emdash-cms/emdash.git",
"directory": "packages/create-emdash"
}
}

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

View File

@@ -0,0 +1,368 @@
import { describe, expect, it } from "vitest";
import { FlagError, HELP_TEXT, parseFlags, validateProjectName, wantsHelp } from "../src/flags.js";
/**
* `parseArgs` consumes argv from index 2 onward, mirroring `process.argv`.
* Helper keeps tests readable.
*/
function argv(...args: string[]): string[] {
return ["node", "create-emdash", ...args];
}
describe("parseFlags — defaults", () => {
it("returns no values when called with no args", () => {
const flags = parseFlags(argv());
expect(flags).toEqual({ yes: false, force: false, help: false });
});
it("treats yes/force/help as false when omitted (not undefined)", () => {
// Important: index.ts checks these directly, so they must be
// boolean — never undefined.
const flags = parseFlags(argv());
expect(flags.yes).toBe(false);
expect(flags.force).toBe(false);
expect(flags.help).toBe(false);
});
});
describe("parseFlags — positional name", () => {
it("captures the project name from the first positional", () => {
const flags = parseFlags(argv("my-blog"));
expect(flags.name).toBe("my-blog");
});
it('accepts "." for current directory', () => {
const flags = parseFlags(argv("."));
expect(flags.name).toBe(".");
});
it("does NOT validate the name itself — that's the resolver's job", () => {
// Validation moved to validateProjectName so the prompt path and the
// flag path use the same rule with the same error message. parseFlags
// is purely structural; it stores whatever the user typed. The
// resolver enforces PROJECT_NAME_PATTERN before scaffolding.
const flags = parseFlags(argv("My-Site"));
expect(flags.name).toBe("My-Site");
});
it("accepts a positional after flags", () => {
const flags = parseFlags(argv("--yes", "my-blog"));
expect(flags.name).toBe("my-blog");
expect(flags.yes).toBe(true);
});
it("rejects extra positionals as a likely typo", () => {
// `npm create emdash my blog` (space instead of hyphen) is the
// killer case: without this check it parses as name="my", drops
// "blog" silently, and creates a project literally named "my".
expect(() => parseFlags(argv("my", "blog"))).toThrow(FlagError);
expect(() => parseFlags(argv("my", "blog"))).toThrow(/Unexpected extra/);
});
it("does not reject extra-position errors when the second token is a flag", () => {
// argv ordering shouldn't trip the extra-positional check — flags
// are removed by parseArgs before we count positionals.
const flags = parseFlags(argv("my-blog", "--yes"));
expect(flags.name).toBe("my-blog");
expect(flags.yes).toBe(true);
});
});
describe("validateProjectName", () => {
it("returns undefined for a valid name", () => {
expect(validateProjectName("my-site")).toBeUndefined();
expect(validateProjectName("blog")).toBeUndefined();
expect(validateProjectName("a")).toBeUndefined();
expect(validateProjectName("123")).toBeUndefined();
});
it('returns undefined for "." (current-dir sentinel)', () => {
expect(validateProjectName(".")).toBeUndefined();
});
it("returns an error message for invalid names", () => {
expect(validateProjectName("My-Site")).toMatch(/lowercase letters/);
expect(validateProjectName("my site")).toMatch(/lowercase letters/);
expect(validateProjectName("my.site")).toMatch(/lowercase letters/);
});
});
describe("parseFlags — --template", () => {
it("accepts a bare template key", () => {
expect(parseFlags(argv("--template", "blog")).template).toBe("blog");
expect(parseFlags(argv("--template", "starter")).template).toBe("starter");
expect(parseFlags(argv("--template", "marketing")).template).toBe("marketing");
expect(parseFlags(argv("--template", "portfolio")).template).toBe("portfolio");
});
it("rejects unknown template keys", () => {
expect(() => parseFlags(argv("--template", "nope"))).toThrow(FlagError);
expect(() => parseFlags(argv("--template", "nope"))).toThrow(/--template/);
});
it("accepts the combined <platform>:<template> form", () => {
const flags = parseFlags(argv("--template", "cloudflare:blog"));
expect(flags.platform).toBe("cloudflare");
expect(flags.template).toBe("blog");
});
it("accepts node:starter via the combined form", () => {
const flags = parseFlags(argv("--template", "node:starter"));
expect(flags.platform).toBe("node");
expect(flags.template).toBe("starter");
});
it("rejects an unknown platform prefix in the combined form", () => {
expect(() => parseFlags(argv("--template", "vercel:blog"))).toThrow(FlagError);
expect(() => parseFlags(argv("--template", "vercel:blog"))).toThrow(/platform prefix/);
});
it("rejects an unknown template name in the combined form", () => {
expect(() => parseFlags(argv("--template", "cloudflare:nope"))).toThrow(FlagError);
});
it("errors when --platform and --template platform-prefix disagree (platform first)", () => {
// Easy to trip over if a user copies a flag set; surfacing an error
// is friendlier than silently letting one win.
expect(() => parseFlags(argv("--platform", "node", "--template", "cloudflare:blog"))).toThrow(
FlagError,
);
expect(() => parseFlags(argv("--platform", "node", "--template", "cloudflare:blog"))).toThrow(
/conflicts/,
);
});
it("errors when --platform and --template platform-prefix disagree (template first)", () => {
// Same as above but argv order reversed — conflict detection must
// be order-independent. Without the reconcile-at-end pattern this
// test passes by accident.
expect(() => parseFlags(argv("--template", "cloudflare:blog", "--platform", "node"))).toThrow(
FlagError,
);
});
it("accepts --platform and --template platform-prefix when they agree", () => {
const flags = parseFlags(argv("--platform", "cloudflare", "--template", "cloudflare:blog"));
expect(flags.platform).toBe("cloudflare");
expect(flags.template).toBe("blog");
});
it("rejects empty halves of the combined form", () => {
expect(() => parseFlags(argv("--template", ":"))).toThrow(FlagError);
expect(() => parseFlags(argv("--template", ":blog"))).toThrow(FlagError);
expect(() => parseFlags(argv("--template", "cloudflare:"))).toThrow(FlagError);
});
it("treats subsequent colons as part of the template name (which then fails)", () => {
// `cloudflare:blog:extra` -> platformPart="cloudflare", templatePart="blog:extra".
// templatePart isn't a valid template, so we get the template error.
expect(() => parseFlags(argv("--template", "cloudflare:blog:extra"))).toThrow(
/--template name must be one of/,
);
});
});
describe("parseFlags — --platform", () => {
it("accepts valid platforms", () => {
expect(parseFlags(argv("--platform", "node")).platform).toBe("node");
expect(parseFlags(argv("--platform", "cloudflare")).platform).toBe("cloudflare");
});
it("rejects unknown platforms", () => {
expect(() => parseFlags(argv("--platform", "vercel"))).toThrow(FlagError);
});
});
describe("parseFlags — package manager", () => {
it("accepts --pm with valid managers", () => {
expect(parseFlags(argv("--pm", "pnpm")).packageManager).toBe("pnpm");
expect(parseFlags(argv("--pm", "npm")).packageManager).toBe("npm");
expect(parseFlags(argv("--pm", "yarn")).packageManager).toBe("yarn");
expect(parseFlags(argv("--pm", "bun")).packageManager).toBe("bun");
});
it("accepts --package-manager as an alias", () => {
expect(parseFlags(argv("--package-manager", "pnpm")).packageManager).toBe("pnpm");
});
it("rejects unknown package managers", () => {
expect(() => parseFlags(argv("--pm", "deno"))).toThrow(FlagError);
});
it("accepts --pm and --package-manager together when they agree", () => {
const flags = parseFlags(argv("--pm", "pnpm", "--package-manager", "pnpm"));
expect(flags.packageManager).toBe("pnpm");
});
it("errors when --pm and --package-manager disagree", () => {
expect(() => parseFlags(argv("--pm", "pnpm", "--package-manager", "npm"))).toThrow(FlagError);
});
});
describe("parseFlags — install toggle", () => {
it("--install sets install: true", () => {
expect(parseFlags(argv("--install")).install).toBe(true);
});
it("--no-install sets install: false", () => {
expect(parseFlags(argv("--no-install")).install).toBe(false);
});
it("install is undefined when neither flag is passed", () => {
expect(parseFlags(argv()).install).toBeUndefined();
});
it("errors when both --install and --no-install are passed", () => {
expect(() => parseFlags(argv("--install", "--no-install"))).toThrow(FlagError);
});
});
describe("parseFlags — --yes / -y", () => {
it("--yes sets yes: true", () => {
expect(parseFlags(argv("--yes")).yes).toBe(true);
});
it("-y sets yes: true", () => {
expect(parseFlags(argv("-y")).yes).toBe(true);
});
});
describe("parseFlags — --force", () => {
it("--force sets force: true", () => {
expect(parseFlags(argv("--force")).force).toBe(true);
});
it("force defaults to false", () => {
expect(parseFlags(argv()).force).toBe(false);
});
it("--force composes with --yes", () => {
const flags = parseFlags(argv("--yes", "--force"));
expect(flags.yes).toBe(true);
expect(flags.force).toBe(true);
});
});
describe("wantsHelp", () => {
it("returns true when --help is present", () => {
expect(wantsHelp(["node", "script", "--help"])).toBe(true);
});
it("returns true when -h is present", () => {
expect(wantsHelp(["node", "script", "-h"])).toBe(true);
});
it("returns true even when --help is preceded by an invalid flag", () => {
// This is the whole point of wantsHelp — we want help even when
// strict parseArgs would reject the rest of argv.
expect(wantsHelp(["node", "script", "--templat", "x", "--help"])).toBe(true);
});
it("returns false when neither flag is present", () => {
expect(wantsHelp(["node", "script"])).toBe(false);
expect(wantsHelp(["node", "script", "my-blog", "--yes"])).toBe(false);
});
it("does not match a positional that happens to spell 'help'", () => {
// A user passing `help` as a project name is invalid in
// PROJECT_NAME_PATTERN terms (it's actually fine — `help` is
// lowercase letters), but the point is wantsHelp matches the flag
// only, not arbitrary tokens.
expect(wantsHelp(["node", "script", "help"])).toBe(false);
});
});
describe("parseFlags — --help / -h", () => {
it("--help sets help: true", () => {
expect(parseFlags(argv("--help")).help).toBe(true);
});
it("-h sets help: true", () => {
expect(parseFlags(argv("-h")).help).toBe(true);
});
});
describe("parseFlags — unknown flags", () => {
it("throws on unknown flags rather than silently ignoring them", () => {
// parseArgs in strict mode (the default) throws TypeError. We
// don't re-wrap it because the message it produces is already
// user-facing-friendly: 'Unknown option \'--templat\''.
expect(() => parseFlags(argv("--templat", "blog"))).toThrow();
});
});
describe("parseFlags — full one-shot install line", () => {
it("parses the example from the issue", () => {
const flags = parseFlags(
argv("my-blog", "--template", "cloudflare:blog", "--pm", "pnpm", "--yes"),
);
expect(flags).toEqual({
name: "my-blog",
platform: "cloudflare",
template: "blog",
packageManager: "pnpm",
yes: true,
force: false,
help: false,
});
});
it("parses split-flag form", () => {
const flags = parseFlags(
argv(
"my-blog",
"--platform",
"node",
"--template",
"starter",
"--pm",
"npm",
"--no-install",
"--yes",
),
);
expect(flags).toEqual({
name: "my-blog",
platform: "node",
template: "starter",
packageManager: "npm",
install: false,
yes: true,
force: false,
help: false,
});
});
it("parses cwd-overwrite form (--yes . --force)", () => {
const flags = parseFlags(argv(".", "--yes", "--force"));
expect(flags.name).toBe(".");
expect(flags.yes).toBe(true);
expect(flags.force).toBe(true);
});
});
describe("HELP_TEXT", () => {
it("documents every supported flag", () => {
// Cheap lint to keep HELP_TEXT in sync with parseFlags. If you
// add a new flag and don't document it, this fails.
for (const flag of [
"--template",
"--platform",
"--pm",
"--package-manager",
"--install",
"--no-install",
"--yes",
"--force",
"--help",
]) {
expect(HELP_TEXT).toContain(flag);
}
});
it("documents the short-form aliases", () => {
expect(HELP_TEXT).toContain("-y");
expect(HELP_TEXT).toContain("-h");
});
});

View File

@@ -0,0 +1,305 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
PROJECT_NAME_PATTERN,
generateEncryptionKey,
isDirNonEmpty,
parseTargetArg,
sanitizePackageName,
writeEncryptionKey,
} from "../src/utils.js";
// ---------------------------------------------------------------------------
// sanitizePackageName
// ---------------------------------------------------------------------------
describe("sanitizePackageName", () => {
it("passes through a valid lowercase name unchanged", () => {
expect(sanitizePackageName("my-site")).toBe("my-site");
});
it("lowercases uppercase characters", () => {
expect(sanitizePackageName("My-Site")).toBe("my-site");
});
it("replaces spaces with hyphens", () => {
expect(sanitizePackageName("my cool site")).toBe("my-cool-site");
});
it("replaces dots with hyphens", () => {
expect(sanitizePackageName("my.site")).toBe("my-site");
});
it("replaces underscores with hyphens", () => {
expect(sanitizePackageName("my_site")).toBe("my-site");
});
it("strips leading hyphens", () => {
expect(sanitizePackageName("--my-site")).toBe("my-site");
});
it("strips trailing hyphens", () => {
expect(sanitizePackageName("my-site--")).toBe("my-site");
});
it("strips both leading and trailing hyphens", () => {
expect(sanitizePackageName("---my-site---")).toBe("my-site");
});
it("handles mixed invalid characters", () => {
expect(sanitizePackageName("My Cool Site!@#2024")).toBe("my-cool-site---2024");
});
it("handles a name that is entirely invalid characters", () => {
expect(sanitizePackageName("!!!")).toBe("my-site");
});
it("handles an empty string", () => {
expect(sanitizePackageName("")).toBe("my-site");
});
it("handles a single period (basename of root on some systems)", () => {
// basename("/") on some platforms can return "/" which sanitises to "my-site"
// but basename of a relative "." is ".", which becomes empty after stripping
expect(sanitizePackageName(".")).toBe("my-site");
});
it("handles names starting with numbers", () => {
expect(sanitizePackageName("123-project")).toBe("123-project");
});
it("handles unicode characters", () => {
expect(sanitizePackageName("mön-prøject")).toBe("m-n-pr-ject");
});
it("collapses multiple consecutive invalid chars into individual hyphens", () => {
// Each invalid char becomes a separate hyphen no collapsing
expect(sanitizePackageName("a b")).toBe("a---b");
});
it("handles CamelCase directory names", () => {
expect(sanitizePackageName("MyProject")).toBe("myproject");
});
it("handles paths that look like scoped packages", () => {
// The @ and / are both invalid, so they become hyphens
expect(sanitizePackageName("@scope/package")).toBe("scope-package");
});
});
// ---------------------------------------------------------------------------
// PROJECT_NAME_PATTERN
// ---------------------------------------------------------------------------
describe("PROJECT_NAME_PATTERN", () => {
const valid = ["my-site", "blog", "a", "123", "my-cool-site-2"];
const invalid = ["My-Site", "my site", "my.site", "my_site", ".", ".hidden", "@scope/pkg", ""];
for (const name of valid) {
it(`accepts "${name}"`, () => {
expect(PROJECT_NAME_PATTERN.test(name)).toBe(true);
});
}
for (const name of invalid) {
it(`rejects "${name}"`, () => {
expect(PROJECT_NAME_PATTERN.test(name)).toBe(false);
});
}
});
// ---------------------------------------------------------------------------
// parseTargetArg
// ---------------------------------------------------------------------------
describe("parseTargetArg", () => {
it("returns undefined when no arguments are passed", () => {
// process.argv always has at least [node, script]
expect(parseTargetArg(["node", "script.js"])).toBeUndefined();
});
it('returns "." when a dot is the first positional argument', () => {
expect(parseTargetArg(["node", "script.js", "."])).toBe(".");
});
it("returns the project name when passed as a positional argument", () => {
expect(parseTargetArg(["node", "script.js", "my-project"])).toBe("my-project");
});
it("skips flags and returns the first positional argument", () => {
expect(parseTargetArg(["node", "script.js", "--verbose", "my-project"])).toBe("my-project");
});
it("skips all flags when no positional argument exists", () => {
expect(parseTargetArg(["node", "script.js", "--verbose", "--debug"])).toBeUndefined();
});
it("returns the first positional argument when multiple are passed", () => {
expect(parseTargetArg(["node", "script.js", "first", "second"])).toBe("first");
});
it("treats a single-hyphen flag as a flag, not a positional arg", () => {
expect(parseTargetArg(["node", "script.js", "-v", "my-project"])).toBe("my-project");
});
});
// ---------------------------------------------------------------------------
// isDirNonEmpty
// ---------------------------------------------------------------------------
describe("isDirNonEmpty", () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "create-emdash-test-"));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("returns false for an empty directory", () => {
expect(isDirNonEmpty(tempDir)).toBe(false);
});
it("returns true for a directory with files", () => {
writeFileSync(join(tempDir, "file.txt"), "hello");
expect(isDirNonEmpty(tempDir)).toBe(true);
});
it("returns true for a directory with subdirectories", () => {
mkdirSync(join(tempDir, "subdir"));
expect(isDirNonEmpty(tempDir)).toBe(true);
});
it("returns false for a non-existent path", () => {
expect(isDirNonEmpty(join(tempDir, "does-not-exist"))).toBe(false);
});
it("returns false for a path that is a file, not a directory", () => {
const filePath = join(tempDir, "a-file.txt");
writeFileSync(filePath, "content");
// readdirSync on a file throws ENOTDIR, which the catch handles
expect(isDirNonEmpty(filePath)).toBe(false);
});
});
// ---------------------------------------------------------------------------
// generateEncryptionKey — format alignment with `emdash`'s parser
// ---------------------------------------------------------------------------
//
// The vendored generator must emit values that pass the canonical-base64url
// check in `packages/core/src/config/secrets.ts`. If the prefix or body
// length ever drifts in core, this test won't catch it directly — but the
// shape assertion here is the same shape `parseEncryptionKeys` checks, so
// any encoding regression will be caught immediately.
//
// We don't import from `emdash` directly because `create-emdash` ships
// without the heavy core dep; the duplication is intentional.
describe("generateEncryptionKey", () => {
it("produces the v1 prefix and a 43-char unpadded base64url body", () => {
const key = generateEncryptionKey();
expect(key).toMatch(/^emdash_enc_v1_[A-Za-z0-9_-]{43}$/);
});
it("body decodes to exactly 32 bytes", () => {
const key = generateEncryptionKey();
const body = key.slice("emdash_enc_v1_".length);
// base64url -> Uint8Array; rely on Node's built-in handling.
const bytes = Buffer.from(body, "base64url");
expect(bytes.length).toBe(32);
});
it("body is canonical (re-encoding decoded bytes yields the same string)", () => {
// Aligns with `parseEncryptionKeys`'s canonical check. If this
// fails, the generator and parser have drifted apart.
const key = generateEncryptionKey();
const body = key.slice("emdash_enc_v1_".length);
const bytes = Buffer.from(body, "base64url");
const reencoded = bytes.toString("base64url");
expect(reencoded).toBe(body);
});
it("produces unique values across calls", () => {
expect(generateEncryptionKey()).not.toBe(generateEncryptionKey());
});
});
// ---------------------------------------------------------------------------
// writeEncryptionKey — parallel coverage with the core CLI's helper
// ---------------------------------------------------------------------------
//
// These cases mirror `tests/unit/cli/secrets-commands.test.ts` in the core
// package. The two implementations are independently maintained (per
// scaffold-time-no-emdash-dep constraint) so both need their own tests.
describe("writeEncryptionKey", () => {
let tempDir: string;
const fileName = ".dev.vars";
const sample = "emdash_enc_v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "create-emdash-key-"));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
function read(): string {
return readFileSync(join(tempDir, fileName), "utf-8");
}
it("creates a new file with a trailing newline when none exists", () => {
const result = writeEncryptionKey(tempDir, fileName);
expect(result).toBe("wrote");
const content = read();
expect(content).toMatch(/^EMDASH_ENCRYPTION_KEY=emdash_enc_v1_[A-Za-z0-9_-]{43}\n$/);
});
it("appends to an existing file without clobbering other vars", () => {
writeFileSync(join(tempDir, fileName), "OTHER=value\nFOO=bar\n");
const result = writeEncryptionKey(tempDir, fileName);
expect(result).toBe("wrote");
const content = read();
expect(content).toMatch(
/^OTHER=value\nFOO=bar\nEMDASH_ENCRYPTION_KEY=emdash_enc_v1_[A-Za-z0-9_-]{43}\n$/,
);
});
it("appends to a file that lacks a trailing newline", () => {
writeFileSync(join(tempDir, fileName), "OTHER=value");
const result = writeEncryptionKey(tempDir, fileName);
expect(result).toBe("wrote");
const content = read();
expect(content).toMatch(
/^OTHER=value\nEMDASH_ENCRYPTION_KEY=emdash_enc_v1_[A-Za-z0-9_-]{43}\n$/,
);
});
it("skips when a populated entry already exists", () => {
writeFileSync(join(tempDir, fileName), `EMDASH_ENCRYPTION_KEY=${sample}\nOTHER=value\n`);
const result = writeEncryptionKey(tempDir, fileName);
expect(result).toBe("skipped");
const content = read();
// File untouched.
expect(content).toBe(`EMDASH_ENCRYPTION_KEY=${sample}\nOTHER=value\n`);
});
it("treats an empty-value entry as not-set and replaces it", () => {
writeFileSync(join(tempDir, fileName), `OTHER=value\nEMDASH_ENCRYPTION_KEY=\nMORE=stuff\n`);
const result = writeEncryptionKey(tempDir, fileName);
expect(result).toBe("wrote");
const content = read();
expect(content).toMatch(
/^OTHER=value\nEMDASH_ENCRYPTION_KEY=emdash_enc_v1_[A-Za-z0-9_-]{43}\nMORE=stuff\n$/,
);
});
it("always ends with a trailing newline, even when replacing in-place in a file without one", () => {
writeFileSync(join(tempDir, fileName), `OTHER=value\nEMDASH_ENCRYPTION_KEY=`);
const result = writeEncryptionKey(tempDir, fileName);
expect(result).toBe("wrote");
const content = read();
expect(content.endsWith("\n")).toBe(true);
});
});

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2024",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"declarationMap": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,11 @@
import { defineConfig } from "tsdown";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
clean: true,
banner: {
js: "#!/usr/bin/env node",
},
});

View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["tests/**/*.test.ts"],
},
});