Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/create-improvements.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions packages/create/src/__tests__/env.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
33 changes: 33 additions & 0 deletions packages/create/src/__tests__/name.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
12 changes: 12 additions & 0 deletions packages/create/src/__tests__/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
36 changes: 36 additions & 0 deletions packages/create/src/__tests__/template.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
67 changes: 54 additions & 13 deletions packages/create/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -14,6 +15,8 @@ export interface CliOptions {
dir?: string;
installer?: string;
yes?: boolean;
install?: boolean;
git?: boolean;
help?: boolean;
version?: boolean;
}
Expand All @@ -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`;

Expand All @@ -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<void> {
Expand Down Expand Up @@ -87,35 +104,49 @@ export async function run(options: CliOptions): Promise<void> {
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) {
template = await promptTemplate();
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.",
Expand All @@ -125,8 +156,9 @@ export async function run(options: CliOptions): Promise<void> {

// `<pm> run <script>` is valid for npm/pnpm/yarn/bun alike.
const script = scripts.dev ? "dev" : scripts.start ? "start" : undefined;
const dir = relative(process.cwd(), projectPath) || ".";
const steps = [
`cd ${relative(process.cwd(), projectPath) || "."}`,
`cd ${/\s/.test(dir) ? `"${dir}"` : dir}`,
...(installed ? [] : [`${installer} install`]),
...(script ? [`${installer} run ${script}`] : []),
];
Expand All @@ -135,8 +167,13 @@ export async function run(options: CliOptions): Promise<void> {
`Next steps:\n${steps.map((step) => color.cyan(` ${step}`)).join("\n")}`,
);
} catch (err) {
spin.stop("Failed to create project", 1);
p.cancel((err as Error).message);
if (spin) spin.stop("Failed to create project", 1);
else p.log.error("Failed to create project");

const error = err as Error & { cause?: unknown };
const detail =
error.cause instanceof Error ? `\n${error.cause.message}` : "";
p.cancel(error.message + detail);
process.exitCode = 1;
}
}
Expand All @@ -161,7 +198,11 @@ async function promptTemplate(): Promise<string | undefined> {

const spin = p.spinner();
spin.start("Loading examples");
const examples = await getExamples();
// Hide the legacy Marko 5 examples from the browse list; they can still be
// used explicitly via `--template <name>-marko-5`.
const examples = (await getExamples()).filter(
({ name }) => !name.endsWith("-marko-5"),
);
spin.stop("Loaded examples");

const example = await p.select({
Expand Down
Loading