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
5 changes: 5 additions & 0 deletions .changeset/ultracite-frameworks-flag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@vernostudio/cli": patch
---

Add `--frameworks` support for Ultracite init: interactive multiselect in create/init wizards, `react next` defaults in `-y` mode, and manifest persistence for selected preset extends
Comment thread
PunGrumpy marked this conversation as resolved.
15 changes: 8 additions & 7 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@ bun x @vernostudio/cli create

Spin up a new project from the stack this repo ships. Pick add-ons, answer prompts, or pass **`--yes`**.

| Mode | What you get |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| **Next.js app** | Single-app layout: Next.js + TypeScript, scoped as `@<scope>/…` |
| **Turborepo** | Monorepo root with `apps/web` and optional `packages/*` |
| **Workspace packages** | `typescript-config` and/or `design-system` (when Turborepo is on; interactive default is both) |
| **shadcn** | `shadcn init` (preset `nova` by default) + `shadcn add --all` — app root, or `packages/design-system` when that package exists |
| **Ultracite** | `ultracite init` with linter **`oxlint`**, **`biome`**, or **`eslint`** (`ultracite init --linter`) |
| Mode | What you get |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Next.js app** | Single-app layout: Next.js + TypeScript, scoped as `@<scope>/…` |
| **Turborepo** | Monorepo root with `apps/web` and optional `packages/*` |
| **Workspace packages** | `typescript-config` and/or `design-system` (when Turborepo is on; interactive default is both) |
| **shadcn** | `shadcn init` (preset `nova` by default) + `shadcn add --all` — app root, or `packages/design-system` when that package exists |
| **Ultracite** | `ultracite init` with linter **`oxlint`**, **`biome`**, or **`eslint`** and frameworks such as **`react`**, **`next`** (`--linter`, `--frameworks`) |

### Non-interactive and dry runs

Expand Down Expand Up @@ -73,6 +73,7 @@ UI & shadcn:

Ultracite:
--linter <id> biome | oxlint | eslint (needs ultracite in --addons; -y defaults to oxlint)
--frameworks <list> react, next, solid, vue, ... (needs ultracite; -y defaults react,next)
Comment thread
PunGrumpy marked this conversation as resolved.
--skip-ultracite Skip ultracite add-on and ultracite init

Install & git:
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/__tests__/create-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,27 @@ describe("resolveCreateInputsNonInteractive", () => {
expect(r.addons).toContain("ultracite");
expect(r.runUltracite).toBe(true);
expect(r.ultraciteLinter).toBe("oxlint");
expect(r.ultraciteFrameworks).toEqual(["react", "next"]);
expect(r.nonInteractive).toBe(true);
});

test("accepts --frameworks react,next with ultracite", () => {
const r = resolveCreateInputsNonInteractive(
"u",
toCreateCommandOptions({ addons: "ultracite", frameworks: "react,next", yes: true }),
);
expect(r.ultraciteFrameworks).toEqual(["react", "next"]);
});

test("rejects --frameworks without ultracite add-on", () => {
expect(() =>
resolveCreateInputsNonInteractive(
"n",
toCreateCommandOptions({ addons: "turborepo", frameworks: "react", yes: true }),
),
).toThrow("--frameworks requires ultracite");
});

test("accepts --linter oxlint with ultracite", () => {
const r = resolveCreateInputsNonInteractive(
"u",
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/__tests__/create-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ describe("buildCreatePlan", () => {
const ultra = steps.find((s) => s.id === "ultracite")?.command;
expect(ultra?.args).toContain("--linter");
expect(ultra?.args).toContain("oxlint");
expect(ultra?.args).toContain("--frameworks");
expect(ultra?.args).toContain("react");
expect(ultra?.args).toContain("next");
});

test("interactive create passes --linter from resolved inputs (wizard or future flags)", () => {
Expand All @@ -39,6 +42,7 @@ describe("buildCreatePlan", () => {
runUltracite: true,
shadcnPreset: "nova",
ui: "shadcn",
ultraciteFrameworks: ["react", "next"],
ultraciteLinter: "oxlint",
useShadcn: true,
};
Expand Down
28 changes: 28 additions & 0 deletions packages/cli/__tests__/init-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,37 @@ describe("resolveInitInputsNonInteractive", () => {
expect(r.addons).toContain("ultracite");
expect(r.runUltracite).toBe(true);
expect(r.ultraciteLinter).toBe("oxlint");
expect(r.ultraciteFrameworks).toEqual(["react", "next"]);
expect(r.nonInteractive).toBe(true);
});

test("accepts --frameworks react next with ultracite", () => {
const r = resolveInitInputsNonInteractive({
addons: "ultracite",
dryRun: false,
frameworks: "react next",
noInstall: false,
skipShadcn: false,
skipUltracite: false,
yes: true,
});
expect(r.ultraciteFrameworks).toEqual(["react", "next"]);
});

test("rejects --frameworks without ultracite add-on", () => {
expect(() =>
resolveInitInputsNonInteractive({
addons: "turborepo",
dryRun: false,
frameworks: "react",
noInstall: false,
skipShadcn: false,
skipUltracite: false,
yes: true,
}),
).toThrow("--frameworks requires ultracite");
});

test("accepts --linter oxlint with ultracite", () => {
const r = resolveInitInputsNonInteractive({
addons: "ultracite",
Expand Down
44 changes: 43 additions & 1 deletion packages/cli/__tests__/pm-exec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,55 @@ describe("getUltraciteInitCommand", () => {
expect(cmd.args).toEqual(["dlx", spec, "init", "--pm", "pnpm", "--linter", "biome", "--quiet"]);
});

test("interactive mode: no --quiet; optional --linter", () => {
test("quiet mode with frameworks passes --frameworks before --quiet", () => {
const spec = getUltraciteExecSpec();
const cmd = getUltraciteInitCommand("pnpm", "quiet", {
frameworks: ["react", "next"],
linter: "oxlint",
});
expect(cmd.args).toEqual([
"dlx",
spec,
"init",
"--pm",
"pnpm",
"--linter",
"oxlint",
"--frameworks",
"react",
"next",
"--quiet",
]);
});

test("interactive mode: no --quiet; optional --linter and --frameworks", () => {
const spec = getUltraciteExecSpec();
const cmd = getUltraciteInitCommand("bun", "interactive", { linter: "oxlint" });
expect(cmd.args).toEqual(["x", spec, "init", "--pm", "bun", "--linter", "oxlint"]);
expect(cmd.args).not.toContain("--quiet");
});

test("interactive mode with frameworks passes --frameworks", () => {
const spec = getUltraciteExecSpec();
const cmd = getUltraciteInitCommand("bun", "interactive", {
frameworks: ["react", "next"],
linter: "oxlint",
});
expect(cmd.args).toEqual([
"x",
spec,
"init",
"--pm",
"bun",
"--linter",
"oxlint",
"--frameworks",
"react",
"next",
]);
expect(cmd.args).not.toContain("--quiet");
});

test("interactive mode without linter omits --linter so Ultracite can prompt", () => {
const spec = getUltraciteExecSpec();
const cmd = getUltraciteInitCommand("bun", "interactive");
Expand Down
31 changes: 31 additions & 0 deletions packages/cli/__tests__/ultracite-framework.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, test } from "bun:test";
import { parseUltraciteFrameworksArg } from "../src/ultracite-framework";
import { parseUltraciteFrameworksFlag } from "../src/commands/shared/ultracite";

describe("parseUltraciteFrameworksArg", () => {
test("parses comma-separated frameworks and dedupes", () => {
expect(parseUltraciteFrameworksArg("react,next,react")).toEqual(["react", "next"]);
});

test("parses space-separated frameworks", () => {
expect(parseUltraciteFrameworksArg("react next")).toEqual(["react", "next"]);
});

test("rejects unknown framework ids", () => {
expect(() => parseUltraciteFrameworksArg("react,unknown")).toThrow(
'Invalid framework "unknown"',
);
});
});

describe("parseUltraciteFrameworksFlag", () => {
test("returns undefined when ultracite is off and flag omitted", () => {
expect(parseUltraciteFrameworksFlag({}, false)).toBeUndefined();
});

test("throws when flag is set without ultracite add-on", () => {
expect(() => parseUltraciteFrameworksFlag({ frameworks: "react" }, false)).toThrow(
"--frameworks requires ultracite",
);
});
});
19 changes: 16 additions & 3 deletions packages/cli/src/commands/create/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import type {
import { FRONTENDS, PACKAGE_IDS } from "@vernostudio/template-generator";
import { DEFAULT_ULTRACITE_LINTER } from "../../ultracite-linter";
import type { UltraciteLinterId } from "../../ultracite-linter";
import { DEFAULT_ULTRACITE_FRAMEWORKS } from "../../ultracite-framework";
import type { UltraciteFrameworkId } from "../../ultracite-framework";
import { parseAddonsArg } from "../shared/addons";
import { splitCommaList } from "../shared/comma-list";
import {
Expand All @@ -16,7 +18,7 @@ import {
PACKAGE_MANAGERS,
} from "../shared/input-primitives";
import type { UiMode } from "../shared/input-primitives";
import { parseUltraciteLinterFlag } from "../shared/ultracite";
import { parseUltraciteFrameworksFlag, parseUltraciteLinterFlag } from "../shared/ultracite";

export { DEFAULT_SHADCN_PRESET, isPackageManager, isUiMode, PACKAGE_MANAGERS, type UiMode };

Expand Down Expand Up @@ -69,6 +71,8 @@ export interface CreateCommandOptions {
readonly shadcnPreset?: string;
/** `ultracite init --linter` when the ultracite add-on is enabled. */
readonly linter?: string;
/** `ultracite init --frameworks` when the ultracite add-on is enabled. */
readonly frameworks?: string;
}

export const toCreateCommandOptions = (raw: {
Expand All @@ -85,9 +89,11 @@ export const toCreateCommandOptions = (raw: {
readonly ui?: string;
readonly shadcnPreset?: string;
readonly linter?: string;
readonly frameworks?: string;
}): CreateCommandOptions => ({
addons: raw.addons,
dryRun: raw.dryRun ?? false,
frameworks: raw.frameworks,
frontend: raw.frontend,
linter: raw.linter,
noGit: raw.noGit ?? false,
Expand Down Expand Up @@ -115,6 +121,8 @@ export interface ResolvedCreateInputs {
readonly packages: readonly PackageId[];
/** Always set when ultracite add-on runs: CLI `--linter`, or interactive wizard, or `-y` default. */
readonly ultraciteLinter?: UltraciteLinterId;
/** Always set when ultracite add-on runs: CLI `--frameworks`, interactive wizard, or `-y` default. */
readonly ultraciteFrameworks?: readonly UltraciteFrameworkId[];
readonly ui: UiMode;
readonly useShadcn: boolean;
}
Expand Down Expand Up @@ -196,8 +204,12 @@ export const resolveCreateInputsNonInteractive = (
const packageManager = resolvePackageManagerNonInteractive(options);
const ui = resolveUiNonInteractive(options);
const ultraciteOn = addons.includes("ultracite");
const flagged = parseUltraciteLinterFlag(options, ultraciteOn);
const ultraciteLinter = ultraciteOn ? (flagged ?? DEFAULT_ULTRACITE_LINTER) : undefined;
const flaggedLinter = parseUltraciteLinterFlag(options, ultraciteOn);
const ultraciteLinter = ultraciteOn ? (flaggedLinter ?? DEFAULT_ULTRACITE_LINTER) : undefined;
const flaggedFrameworks = parseUltraciteFrameworksFlag(options, ultraciteOn);
const ultraciteFrameworks = ultraciteOn
? (flaggedFrameworks ?? DEFAULT_ULTRACITE_FRAMEWORKS)
: undefined;

return {
addons,
Expand All @@ -211,6 +223,7 @@ export const resolveCreateInputsNonInteractive = (
runUltracite: ultraciteOn,
shadcnPreset: options.shadcnPreset ?? DEFAULT_SHADCN_PRESET,
ui,
ultraciteFrameworks,
ultraciteLinter,
useShadcn: ui === "shadcn" && !options.skipShadcn,
};
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/create/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export const runCreate = async (args: {
},
ultracite: {
enabled: resolved.runUltracite,
frameworks: resolved.ultraciteFrameworks,
linter: resolved.ultraciteLinter,
nonInteractive: resolved.nonInteractive,
},
Expand Down Expand Up @@ -153,6 +154,7 @@ export const runCreate = async (args: {
skip_git: !resolved.doGit,
skip_install: !resolved.doInstall,
ui: resolved.ui,
ultracite_frameworks: resolved.ultraciteFrameworks?.join(","),
});

printDoneNextSteps(`Project "${resolved.name}" is ready.`, nextSteps);
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/commands/create/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,12 @@ export const buildCreatePlan = (
appendUltraciteStep(steps, {
id: "ultracite",
mode: resolved.runUltracite
? { kind: "run", linter: resolved.ultraciteLinter, nonInteractive: resolved.nonInteractive }
? {
frameworks: resolved.ultraciteFrameworks,
kind: "run",
linter: resolved.ultraciteLinter,
nonInteractive: resolved.nonInteractive,
}
: { kind: "skip", reason: "Skipped (ultracite add-on off, --skip-ultracite, or declined)" },
packageManager: resolved.packageManager,
projectDir,
Expand Down
36 changes: 32 additions & 4 deletions packages/cli/src/commands/create/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import type { AddonId, PackageId, PackageManager } from "@vernostudio/template-g
import { UserCancelledError } from "../../errors";
import { renderVernoTitle } from "../../ui";
import { parseAddonsArg } from "../shared/addons";
import { parseUltraciteLinterFlag } from "../shared/ultracite";
import { parseUltraciteFrameworksFlag, parseUltraciteLinterFlag } from "../shared/ultracite";
import { DEFAULT_ULTRACITE_FRAMEWORKS, ULTRACITE_FRAMEWORK_IDS } from "../../ultracite-framework";
import type { UltraciteFrameworkId } from "../../ultracite-framework";
import { DEFAULT_ULTRACITE_LINTER } from "../../ultracite-linter";
import type { UltraciteLinterId } from "../../ultracite-linter";
import {
DEFAULT_SHADCN_PRESET,
DEFAULT_WORKSPACE_PACKAGES,
Expand All @@ -15,8 +19,6 @@ import {
parsePackagesArg,
} from "./args";
import type { CreateCommandOptions, ResolvedCreateInputs, UiMode } from "./args";
import { DEFAULT_ULTRACITE_LINTER } from "../../ultracite-linter";
import type { UltraciteLinterId } from "../../ultracite-linter";

const exitOnCancel = (value: unknown): void => {
if (p.isCancel(value)) {
Expand Down Expand Up @@ -244,6 +246,30 @@ const readUltraciteLinter = async (
);
};

const readUltraciteFrameworks = async (
options: CreateCommandOptions,
ultraciteOn: boolean,
): Promise<UltraciteFrameworkId[] | undefined> => {
const fromFlag = parseUltraciteFrameworksFlag(options, ultraciteOn);
if (!ultraciteOn) {
return undefined;
}
if (fromFlag !== undefined) {
return fromFlag;
}
return assertValue(
await p.multiselect<UltraciteFrameworkId>({
initialValues: [...DEFAULT_ULTRACITE_FRAMEWORKS],
message: "Ultracite frameworks (passed as --frameworks)",
options: ULTRACITE_FRAMEWORK_IDS.map((id) => ({
label: id,
value: id,
})),
required: true,
}),
);
};
Comment thread
PunGrumpy marked this conversation as resolved.

export const runInteractiveCreateWizard = async (args: {
readonly name?: string;
readonly options: CreateCommandOptions;
Expand All @@ -267,6 +293,7 @@ export const runInteractiveCreateWizard = async (args: {
const packages = await readPackagesInteractive(options, turborepoOn);
const ultraciteOn = addons.includes("ultracite");
const ultraciteLinter = await readUltraciteLinter(options, ultraciteOn);
const ultraciteFrameworks = await readUltraciteFrameworks(options, ultraciteOn);

p.log.step("Tooling — package manager and UI");
const packageManager = await readPackageManager(options);
Expand All @@ -280,7 +307,7 @@ export const runInteractiveCreateWizard = async (args: {
const useShadcn = ui === "shadcn" && !hasSkipShadcn;
const runUltracite = ultraciteOn;
const ultraciteSummary = runUltracite
? `yes — ultracite init --linter ${ultraciteLinter ?? DEFAULT_ULTRACITE_LINTER} (after install)`
? `yes — ultracite init --linter ${ultraciteLinter ?? DEFAULT_ULTRACITE_LINTER} --frameworks ${(ultraciteFrameworks ?? DEFAULT_ULTRACITE_FRAMEWORKS).join(" ")} (after install)`
: "no";

const summaryLines: string[] = [
Expand Down Expand Up @@ -322,6 +349,7 @@ export const runInteractiveCreateWizard = async (args: {
runUltracite,
shadcnPreset,
ui,
ultraciteFrameworks,
ultraciteLinter,
useShadcn,
};
Expand Down
Loading