Skip to content

Commit c16c58d

Browse files
authoredMar 5, 2025
feat(shadcn): add --template flag (#6863)
* feat(shadcn): add --template flag * chore: changeset * fix: type
1 parent a3fe507 commit c16c58d

File tree

5 files changed

+171
-17
lines changed

5 files changed

+171
-17
lines changed
 

‎.changeset/metal-queens-hang.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"shadcn": patch
3+
---
4+
5+
add --template flag

‎packages/shadcn/src/commands/add.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ export const add = new Command()
149149

150150
let shouldUpdateAppIndex = false
151151
if (errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT]) {
152-
const { projectPath, projectType } = await createProject({
152+
const { projectPath, template } = await createProject({
153153
cwd: options.cwd,
154154
force: options.overwrite,
155155
srcDir: options.srcDir,
@@ -161,7 +161,7 @@ export const add = new Command()
161161
}
162162
options.cwd = projectPath
163163

164-
if (projectType === "monorepo") {
164+
if (template === "next-monorepo") {
165165
options.cwd = path.resolve(options.cwd, "apps/web")
166166
config = await getConfig(options.cwd)
167167
} else {

‎packages/shadcn/src/commands/init.ts

+24-5
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import path from "path"
33
import { preFlightInit } from "@/src/preflights/preflight-init"
44
import { getRegistryBaseColors, getRegistryStyles } from "@/src/registry/api"
55
import { addComponents } from "@/src/utils/add-components"
6-
import { createProject } from "@/src/utils/create-project"
6+
import { TEMPLATES, createProject } from "@/src/utils/create-project"
77
import * as ERRORS from "@/src/utils/errors"
88
import {
99
DEFAULT_COMPONENTS,
@@ -39,6 +39,20 @@ export const initOptionsSchema = z.object({
3939
isNewProject: z.boolean(),
4040
srcDir: z.boolean().optional(),
4141
cssVariables: z.boolean(),
42+
template: z
43+
.string()
44+
.optional()
45+
.refine(
46+
(val) => {
47+
if (val) {
48+
return TEMPLATES[val as keyof typeof TEMPLATES]
49+
}
50+
return true
51+
},
52+
{
53+
message: "Invalid template. Please use 'next' or 'next-monorepo'.",
54+
}
55+
),
4256
})
4357

4458
export const init = new Command()
@@ -48,6 +62,11 @@ export const init = new Command()
4862
"[components...]",
4963
"the components to add or a url to the component."
5064
)
65+
.option(
66+
"-t, --template <template>",
67+
"the template to use. (next, next-monorepo)",
68+
""
69+
)
5170
.option("-y, --yes", "skip confirmation prompt.", true)
5271
.option("-d, --defaults,", "use default configuration.", false)
5372
.option("-f, --force", "force overwrite of existing configuration.", false)
@@ -97,24 +116,24 @@ export async function runInit(
97116
}
98117
) {
99118
let projectInfo
100-
let newProjectType
119+
let newProjectTemplate
101120
if (!options.skipPreflight) {
102121
const preflight = await preFlightInit(options)
103122
if (preflight.errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT]) {
104-
const { projectPath, projectType } = await createProject(options)
123+
const { projectPath, template } = await createProject(options)
105124
if (!projectPath) {
106125
process.exit(1)
107126
}
108127
options.cwd = projectPath
109128
options.isNewProject = true
110-
newProjectType = projectType
129+
newProjectTemplate = template
111130
}
112131
projectInfo = preflight.projectInfo
113132
} else {
114133
projectInfo = await getProjectInfo(options.cwd)
115134
}
116135

117-
if (newProjectType === "monorepo") {
136+
if (newProjectTemplate === "next-monorepo") {
118137
options.cwd = path.resolve(options.cwd, "apps/web")
119138
return await getConfig(options.cwd)
120139
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { fetchRegistry } from "@/src/registry/api"
2+
import { execa } from "execa"
3+
import fs from "fs-extra"
4+
import prompts from "prompts"
5+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
6+
7+
import { TEMPLATES, createProject } from "./create-project"
8+
9+
// Mock dependencies
10+
vi.mock("fs-extra")
11+
vi.mock("execa")
12+
vi.mock("prompts")
13+
vi.mock("@/src/registry/api")
14+
vi.mock("@/src/utils/get-package-manager", () => ({
15+
getPackageManager: vi.fn().mockResolvedValue("npm"),
16+
}))
17+
18+
describe("createProject", () => {
19+
beforeEach(() => {
20+
vi.clearAllMocks()
21+
vi.mocked(fs.access).mockResolvedValue(undefined)
22+
vi.mocked(fs.existsSync).mockReturnValue(false)
23+
})
24+
25+
afterEach(() => {
26+
vi.resetAllMocks()
27+
})
28+
29+
it("should create a Next.js project with default options", async () => {
30+
vi.mocked(prompts).mockResolvedValue({ type: "next", name: "my-app" })
31+
32+
const result = await createProject({
33+
cwd: "/test",
34+
force: false,
35+
srcDir: false,
36+
})
37+
38+
expect(result).toEqual({
39+
projectPath: "/test/my-app",
40+
projectName: "my-app",
41+
template: TEMPLATES.next,
42+
})
43+
44+
expect(execa).toHaveBeenCalledWith(
45+
"npx",
46+
expect.arrayContaining(["create-next-app@latest", "/test/my-app"]),
47+
expect.any(Object)
48+
)
49+
})
50+
51+
it("should create a monorepo project when selected", async () => {
52+
vi.mocked(prompts).mockResolvedValue({
53+
type: "next-monorepo",
54+
name: "my-monorepo",
55+
})
56+
57+
const result = await createProject({
58+
cwd: "/test",
59+
force: false,
60+
srcDir: false,
61+
})
62+
63+
expect(result).toEqual({
64+
projectPath: "/test/my-monorepo",
65+
projectName: "my-monorepo",
66+
template: TEMPLATES["next-monorepo"],
67+
})
68+
})
69+
70+
it("should handle remote components and force next template", async () => {
71+
vi.mocked(fetchRegistry).mockResolvedValue([
72+
{
73+
meta: { nextVersion: "13.0.0" },
74+
},
75+
])
76+
77+
const result = await createProject({
78+
cwd: "/test",
79+
force: true,
80+
components: ["/chat/b/some-component"],
81+
})
82+
83+
expect(result.template).toBe(TEMPLATES.next)
84+
})
85+
86+
it("should throw error if project path already exists", async () => {
87+
vi.mocked(fs.existsSync).mockReturnValue(true)
88+
vi.mocked(prompts).mockResolvedValue({ type: "next", name: "existing-app" })
89+
90+
const mockExit = vi
91+
.spyOn(process, "exit")
92+
.mockImplementation(() => undefined as never)
93+
94+
await createProject({
95+
cwd: "/test",
96+
force: false,
97+
})
98+
99+
expect(mockExit).toHaveBeenCalledWith(1)
100+
})
101+
102+
it("should throw error if path is not writable", async () => {
103+
vi.mocked(fs.access).mockRejectedValue(new Error("Permission denied"))
104+
vi.mocked(prompts).mockResolvedValue({ type: "next", name: "my-app" })
105+
106+
const mockExit = vi
107+
.spyOn(process, "exit")
108+
.mockImplementation(() => undefined as never)
109+
110+
await createProject({
111+
cwd: "/test",
112+
force: false,
113+
})
114+
115+
expect(mockExit).toHaveBeenCalledWith(1)
116+
})
117+
})

‎packages/shadcn/src/utils/create-project.ts

+23-10
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,29 @@ import { z } from "zod"
1515
const MONOREPO_TEMPLATE_URL =
1616
"https://codeload.github.com/shadcn-ui/ui/tar.gz/main"
1717

18+
export const TEMPLATES = {
19+
next: "next",
20+
"next-monorepo": "next-monorepo",
21+
} as const
22+
1823
export async function createProject(
1924
options: Pick<
2025
z.infer<typeof initOptionsSchema>,
21-
"cwd" | "force" | "srcDir" | "components"
26+
"cwd" | "force" | "srcDir" | "components" | "template"
2227
>
2328
) {
2429
options = {
2530
srcDir: false,
2631
...options,
2732
}
2833

29-
let projectType: "next" | "monorepo" = "next"
30-
let projectName: string = "my-app"
31-
let nextVersion = "canary"
34+
let template: keyof typeof TEMPLATES =
35+
options.template && TEMPLATES[options.template as keyof typeof TEMPLATES]
36+
? (options.template as keyof typeof TEMPLATES)
37+
: "next"
38+
let projectName: string =
39+
template === TEMPLATES.next ? "my-app" : "my-monorepo"
40+
let nextVersion = "latest"
3241

3342
const isRemoteComponent =
3443
options.components?.length === 1 &&
@@ -45,6 +54,9 @@ export async function createProject(
4554
})
4655
.parse(result)
4756
nextVersion = meta.nextVersion
57+
58+
// Force template to next for remote components.
59+
template = TEMPLATES.next
4860
} catch (error) {
4961
logger.break()
5062
handleError(error)
@@ -54,14 +66,14 @@ export async function createProject(
5466
if (!options.force) {
5567
const { type, name } = await prompts([
5668
{
57-
type: "select",
69+
type: options.template || isRemoteComponent ? null : "select",
5870
name: "type",
5971
message: `The path ${highlighter.info(
6072
options.cwd
6173
)} does not contain a package.json file.\n Would you like to start a new project?`,
6274
choices: [
6375
{ title: "Next.js", value: "next" },
64-
{ title: "Next.js (Monorepo)", value: "monorepo" },
76+
{ title: "Next.js (Monorepo)", value: "next-monorepo" },
6577
],
6678
initial: 0,
6779
},
@@ -78,7 +90,7 @@ export async function createProject(
7890
},
7991
])
8092

81-
projectType = type
93+
template = type ?? template
8294
projectName = name
8395
}
8496

@@ -113,7 +125,7 @@ export async function createProject(
113125
process.exit(1)
114126
}
115127

116-
if (projectType === "next") {
128+
if (template === TEMPLATES.next) {
117129
await createNextProject(projectPath, {
118130
version: nextVersion,
119131
cwd: options.cwd,
@@ -122,7 +134,7 @@ export async function createProject(
122134
})
123135
}
124136

125-
if (projectType === "monorepo") {
137+
if (template === TEMPLATES["next-monorepo"]) {
126138
await createMonorepoProject(projectPath, {
127139
packageManager,
128140
})
@@ -131,7 +143,7 @@ export async function createProject(
131143
return {
132144
projectPath,
133145
projectName,
134-
projectType,
146+
template,
135147
}
136148
}
137149

@@ -161,6 +173,7 @@ async function createNextProject(
161173

162174
if (
163175
options.version.startsWith("15") ||
176+
options.version.startsWith("latest") ||
164177
options.version.startsWith("canary")
165178
) {
166179
args.push("--turbopack")

0 commit comments

Comments
 (0)