From 448543e23adb7c40349abef1c0b4778a9f26f361 Mon Sep 17 00:00:00 2001 From: Dylan Piercey Date: Wed, 22 Jul 2026 11:03:26 -0700 Subject: [PATCH] feat(create): name validation, --no-install/--no-git, GitHub auth, plain CI output, robustness --- .changeset/create-improvements.md | 6 ++ packages/create/src/__tests__/env.test.ts | 28 +++++++ packages/create/src/__tests__/name.test.ts | 33 ++++++++ packages/create/src/__tests__/parse.test.ts | 12 +++ .../create/src/__tests__/template.test.ts | 36 ++++++++ packages/create/src/cli.ts | 67 ++++++++++++--- packages/create/src/create.ts | 82 +++++++++++++++---- packages/create/src/env.ts | 4 + packages/create/src/name.ts | 21 +++++ 9 files changed, 260 insertions(+), 29 deletions(-) create mode 100644 .changeset/create-improvements.md create mode 100644 packages/create/src/__tests__/env.test.ts create mode 100644 packages/create/src/__tests__/name.test.ts create mode 100644 packages/create/src/__tests__/template.test.ts create mode 100644 packages/create/src/name.ts diff --git a/.changeset/create-improvements.md b/.changeset/create-improvements.md new file mode 100644 index 0000000..ba5bd89 --- /dev/null +++ b/.changeset/create-improvements.md @@ -0,0 +1,6 @@ +--- +"@marko/create": minor +"create-marko": minor +--- + +CLI improvements: validate the project name and derive a valid npm package name from it (e.g. `My App` → `my-app`); add `--no-install` and `--no-git`; hide the legacy `*-marko-5` examples from the interactive list (still usable via `--template`); authenticate GitHub requests with `GITHUB_TOKEN`/`GH_TOKEN` when set (higher rate limits and private templates), with clearer rate-limit errors; use plain step output instead of an animated spinner under CI/agents/piped output; and fail clearly on a missing template (and surface the underlying cause) instead of scaffolding an empty project. diff --git a/packages/create/src/__tests__/env.test.ts b/packages/create/src/__tests__/env.test.ts new file mode 100644 index 0000000..29aaa51 --- /dev/null +++ b/packages/create/src/__tests__/env.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { detectInstaller, githubToken } from "../env.js"; + +afterEach(() => vi.unstubAllEnvs()); + +describe("detectInstaller", () => { + it("reads the package manager from npm_config_user_agent", () => { + vi.stubEnv("npm_config_user_agent", "pnpm/9.0.0 npm/? node/v22 linux x64"); + expect(detectInstaller()).toBe("pnpm"); + }); + + it("falls back to npm", () => { + vi.stubEnv("npm_config_user_agent", ""); + expect(detectInstaller()).toBe("npm"); + }); +}); + +describe("githubToken", () => { + it("prefers GITHUB_TOKEN, then GH_TOKEN", () => { + vi.stubEnv("GH_TOKEN", "gh"); + vi.stubEnv("GITHUB_TOKEN", "primary"); + expect(githubToken()).toBe("primary"); + + vi.stubEnv("GITHUB_TOKEN", ""); + expect(githubToken()).toBe("gh"); + }); +}); diff --git a/packages/create/src/__tests__/name.test.ts b/packages/create/src/__tests__/name.test.ts new file mode 100644 index 0000000..2e6c71b --- /dev/null +++ b/packages/create/src/__tests__/name.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { isValidProjectName, toPackageName } from "../name.js"; + +describe("toPackageName", () => { + it("lowercases and dashes spaces", () => { + expect(toPackageName("My App")).toBe("my-app"); + }); + + it("keeps an already-valid name", () => { + expect(toPackageName("basic")).toBe("basic"); + }); + + it("strips leading dots and underscores", () => { + expect(toPackageName("._foo")).toBe("foo"); + }); + + it("falls back to 'app' when nothing usable remains", () => { + expect(toPackageName("!!!")).toBe("app"); + }); +}); + +describe("isValidProjectName", () => { + it("accepts a simple name", () => { + expect(isValidProjectName("my-app")).toBe(true); + }); + + it("rejects empty and slashed names", () => { + expect(isValidProjectName(" ")).toBe(false); + expect(isValidProjectName("a/b")).toBe(false); + expect(isValidProjectName("a\\b")).toBe(false); + }); +}); diff --git a/packages/create/src/__tests__/parse.test.ts b/packages/create/src/__tests__/parse.test.ts index e813cfc..a0c780c 100644 --- a/packages/create/src/__tests__/parse.test.ts +++ b/packages/create/src/__tests__/parse.test.ts @@ -29,4 +29,16 @@ describe("parse", () => { const options = parse(["-n", "example", "-y"]); expect(options.yes).toBe(true); }); + + it("parses --no-install and --no-git as opt-outs", () => { + const options = parse(["-n", "example", "--no-install", "--no-git"]); + expect(options.install).toBe(false); + expect(options.git).toBe(false); + }); + + it("leaves install/git undefined by default", () => { + const options = parse(["-n", "example"]); + expect(options.install).toBeUndefined(); + expect(options.git).toBeUndefined(); + }); }); diff --git a/packages/create/src/__tests__/template.test.ts b/packages/create/src/__tests__/template.test.ts new file mode 100644 index 0000000..276141b --- /dev/null +++ b/packages/create/src/__tests__/template.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { parseTemplate } from "../create.js"; + +describe("parseTemplate", () => { + it("maps a named example to marko-js/examples", () => { + expect(parseTemplate("basic")).toEqual({ + input: "github:marko-js/examples/examples/basic", + repo: "marko-js/examples", + ref: undefined, + }); + }); + + it("carries a ref on a named example", () => { + expect(parseTemplate("basic#next")).toMatchObject({ + input: "github:marko-js/examples/examples/basic", + ref: "next", + }); + }); + + it("maps a user/repo git template", () => { + expect(parseTemplate("user/repo")).toEqual({ + input: "github:user/repo", + repo: "user/repo", + ref: undefined, + }); + }); + + it("supports a subdirectory and ref", () => { + expect(parseTemplate("user/repo/packages/app#v1")).toMatchObject({ + input: "github:user/repo/packages/app", + repo: "user/repo", + ref: "v1", + }); + }); +}); diff --git a/packages/create/src/cli.ts b/packages/create/src/cli.ts index a63649f..235df07 100644 --- a/packages/create/src/cli.ts +++ b/packages/create/src/cli.ts @@ -6,6 +6,7 @@ import color from "picocolors"; import { createProject, DEFAULT_TEMPLATE, getExamples } from "./create.js"; import { isAgent, isCI } from "./env.js"; +import { isValidProjectName } from "./name.js"; import { version } from "./pkg.js"; export interface CliOptions { @@ -14,6 +15,8 @@ export interface CliOptions { dir?: string; installer?: string; yes?: boolean; + install?: boolean; + git?: boolean; help?: boolean; version?: boolean; } @@ -28,6 +31,8 @@ ${color.bold("Options:")} -d, --dir Directory to create the app in (default: current directory) -i, --installer Package manager to install with (default: detected) -y, --yes Skip prompts and accept defaults (implied under CI and agents) + --no-install Skip installing dependencies + --no-git Skip initializing a git repository -v, --version Print the version -h, --help Show this help message`; @@ -41,12 +46,24 @@ export function parse(argv: string[]): CliOptions { dir: { type: "string", short: "d" }, installer: { type: "string", short: "i" }, yes: { type: "boolean", short: "y" }, + "no-install": { type: "boolean" }, + "no-git": { type: "boolean" }, help: { type: "boolean", short: "h" }, version: { type: "boolean", short: "v" }, }, }); - return { ...values, name: values.name ?? positionals[0] }; + return { + name: values.name ?? positionals[0], + template: values.template, + dir: values.dir, + installer: values.installer, + yes: values.yes, + install: values["no-install"] ? false : undefined, + git: values["no-git"] ? false : undefined, + help: values.help, + version: values.version, + }; } export async function run(options: CliOptions): Promise { @@ -87,9 +104,14 @@ export async function run(options: CliOptions): Promise { message: "What is your project named?", placeholder: "my-app", defaultValue: "my-app", + validate: (value) => { + if (value && !isValidProjectName(value)) { + return "Name can't contain slashes."; + } + }, }); if (p.isCancel(answer)) return cancelled(); - name = answer || "my-app"; + name = answer.trim() || "my-app"; } if (!template) { @@ -97,25 +119,34 @@ export async function run(options: CliOptions): Promise { if (template === undefined) return cancelled(); } - const spin = p.spinner(); - spin.start("Setting up project"); + // Skip the animated spinner when nothing is watching a terminal (agents/CI/ + // piped output) — it would otherwise spam frames into captured logs. + const plain = !process.stdout.isTTY || isAgent() || isCI(); + const spin = plain ? undefined : p.spinner(); + spin?.start("Setting up project"); + const step = (message: string) => + spin ? spin.message(message) : p.log.step(message); + + let installFailed = false; let installLog = ""; const result = createProject({ ...options, name, template }); - result.on("download", () => spin.message("Downloading app")); + result.on("download", () => step("Downloading app")); result.on("install", (installer: string) => - spin.message(`Installing dependencies with ${installer}`), + step(`Installing dependencies with ${installer}`), ); result.on("install-error", (_installer: string, log?: string) => { + installFailed = true; installLog = log ?? ""; }); - result.on("init", () => spin.message("Setting up git repository")); + result.on("init", () => step("Setting up git repository")); try { const { projectPath, installer, installed, scripts } = await result; - spin.stop("Project created"); + if (spin) spin.stop("Project created"); + else p.log.success("Project created"); - if (!installed) { + if (installFailed) { p.log.warn( `${color.cyan(`${installer} install`)} did not finish cleanly — you ` + "may need to run it yourself.", @@ -125,8 +156,9 @@ export async function run(options: CliOptions): Promise { // ` run