diff --git a/packages/shadcn/src/commands/init.ts b/packages/shadcn/src/commands/init.ts index 068b8f94b73..b24743032b9 100644 --- a/packages/shadcn/src/commands/init.ts +++ b/packages/shadcn/src/commands/init.ts @@ -118,11 +118,11 @@ export const init = new Command() .action(async (components, opts) => { try { const options = initOptionsSchema.parse({ - cwd: path.resolve(opts.cwd), isNewProject: false, - components, style: "index", - ...opts, + ...opts, /** @fixed Place this before setting cwd to ensure the resolved cwd don't get overridden. */ + cwd: path.resolve(opts.cwd), + components, }) // We need to check if we're initializing with a new style. diff --git a/packages/shadcn/src/utils/create-project.test.ts b/packages/shadcn/src/utils/create-project.test.ts index 4a6a5ae8bd4..fb9d81e97cc 100644 --- a/packages/shadcn/src/utils/create-project.test.ts +++ b/packages/shadcn/src/utils/create-project.test.ts @@ -3,6 +3,8 @@ import { spinner } from "@/src/utils/spinner" import { execa } from "execa" import fs from "fs-extra" import prompts from "prompts" +import path from "path" +import { logger } from "@/src/utils/logger" import { afterEach, beforeEach, @@ -33,18 +35,21 @@ vi.mock("@/src/utils/logger", () => ({ })) describe("createProject", () => { - let mockExit: MockInstance + let mockLoggerError: ReturnType; + let mockExit: MockInstance; beforeEach(() => { vi.clearAllMocks() // Reset all fs mocks + vi.mocked(fs.mkdirSync).mockReturnValue(undefined) + vi.mocked(fs.opendirSync).mockReturnValue({} as fs.Dir) vi.mocked(fs.access).mockResolvedValue(undefined) vi.mocked(fs.existsSync).mockReturnValue(false) vi.mocked(fs.ensureDir).mockResolvedValue(undefined) vi.mocked(fs.writeFile).mockResolvedValue(undefined) vi.mocked(fs.move).mockResolvedValue(undefined) - vi.mocked(fs.remove).mockResolvedValue(undefined) + vi.mocked(fs.remove).mockResolvedValue(undefined) // Mock execa to resolve immediately without actual execution vi.mocked(execa).mockResolvedValue({ @@ -61,46 +66,54 @@ describe("createProject", () => { killed: false, } as any) - // Mock fetch for monorepo template - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), - } as any) + mockLoggerError = vi.spyOn(logger, 'error').mockImplementation(() => {}); + mockExit = vi.spyOn(process, "exit").mockImplementation(() => { + return undefined as never + }) - // Reset prompts mock - vi.mocked(prompts).mockResolvedValue({ type: "next", name: "my-app" }) + // Mock fetch for monorepo template + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + } as any) - // Reset registry mock - vi.mocked(fetchRegistry).mockResolvedValue([]) - - // Mock spinner function - const mockSpinner = { - start: vi.fn().mockReturnThis(), - succeed: vi.fn().mockReturnThis(), - fail: vi.fn().mockReturnThis(), - stop: vi.fn().mockReturnThis(), - text: "", - prefixText: "", - suffixText: "", - color: "cyan" as const, - indent: 0, - spinner: "dots" as const, - isSpinning: false, - interval: 100, - stream: process.stderr, - clear: vi.fn(), - render: vi.fn(), - frame: vi.fn(), - stopAndPersist: vi.fn(), - warn: vi.fn(), - info: vi.fn(), - } - vi.mocked(spinner).mockReturnValue(mockSpinner as any) - }) + // Reset prompts mock + vi.mocked(prompts).mockResolvedValue({ type: "next", name: "my-app" }) + + // Reset registry mock + vi.mocked(fetchRegistry).mockResolvedValue([]) + + // Mock spinner function + const mockSpinner = { + start: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + stop: vi.fn().mockReturnThis(), + text: "", + prefixText: "", + suffixText: "", + color: "cyan" as const, + indent: 0, + spinner: "dots" as const, + isSpinning: false, + interval: 100, + stream: process.stderr, + clear: vi.fn(), + render: vi.fn(), + frame: vi.fn(), + stopAndPersist: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + } + + vi.mocked(spinner).mockReturnValue(mockSpinner as any) +}) afterEach(() => { vi.resetAllMocks() - mockExit?.mockRestore() + + mockLoggerError.mockRestore() + mockExit.mockRestore() delete (global as any).fetch }) @@ -108,20 +121,24 @@ describe("createProject", () => { vi.mocked(prompts).mockResolvedValue({ type: "next", name: "my-app" }) const result = await createProject({ - cwd: "/test", + cwd: path.resolve("/test"), force: false, srcDir: false, }) + const cwd = "/test" + const projectPath = "my-app" + const resolvedPath = path.resolve(cwd, projectPath) + expect(result).toEqual({ - projectPath: "/test/my-app", - projectName: "my-app", + projectPath: resolvedPath, + projectName: projectPath, template: TEMPLATES.next, }) expect(execa).toHaveBeenCalledWith( "npx", - expect.arrayContaining(["create-next-app@latest", "/test/my-app"]), + expect.arrayContaining(["create-next-app@latest", resolvedPath]), expect.any(Object) ) }) @@ -133,13 +150,13 @@ describe("createProject", () => { }) const result = await createProject({ - cwd: "/test", + cwd: path.resolve("/test"), force: false, srcDir: false, }) expect(result).toEqual({ - projectPath: "/test/my-monorepo", + projectPath: path.resolve("/test", "my-monorepo"), projectName: "my-monorepo", template: TEMPLATES["next-monorepo"], }) @@ -161,38 +178,107 @@ describe("createProject", () => { expect(result.template).toBe(TEMPLATES.next) }) - it("should throw error if project path already exists", async () => { - // Mock fs.existsSync to return true only for the specific package.json path - vi.mocked(fs.existsSync).mockImplementation((path: any) => { - return path.toString().includes("existing-app/package.json") - }) - vi.mocked(prompts).mockResolvedValue({ type: "next", name: "existing-app" }) + it('should throw error if project path already exists and is not empty (error EEXIST)', async () => { + const cwd = '/test'; + const projectName = 'existing-app'; + const projectPath = path.resolve(cwd, projectName); - mockExit = vi - .spyOn(process, "exit") - .mockImplementation(() => undefined as never) + let mockedDirHandle: any; - await createProject({ - cwd: "/test", - force: false, - }) + vi.mocked(prompts).mockResolvedValue({ type: 'next', name: projectName }); - expect(mockExit).toHaveBeenCalledWith(1) - }) + vi.mocked(fs.mkdirSync).mockImplementation((pathArg) => { + const resolvedArg = path.resolve(cwd, String(pathArg)); + if (resolvedArg === projectPath) { + const error: any = new Error('File already exists'); + error.code = 'EEXIST'; + throw error; + } + return undefined; + }); - it("should throw error if path is not writable", async () => { - vi.mocked(fs.access).mockRejectedValue(new Error("Permission denied")) - vi.mocked(prompts).mockResolvedValue({ type: "next", name: "my-app" }) + vi.mocked(fs.opendirSync).mockImplementation((pathArg) => { + const resolvedArg = path.resolve(cwd, String(pathArg)); + if (resolvedArg === projectPath) { + mockedDirHandle = { + readSync: vi.fn().mockReturnValueOnce({ name: 'somefile.txt' }).mockReturnValue(null), + closeSync: vi.fn(), + }; + return mockedDirHandle; + } + return { + readSync: vi.fn().mockReturnValue(null), + closeSync: vi.fn(), + }; + }); - mockExit = vi - .spyOn(process, "exit") - .mockImplementation(() => undefined as never) + await createProject({ cwd, force: false }); - await createProject({ - cwd: "/test", - force: false, + expect(mockLoggerError).toHaveBeenCalledWith(`Directory ${projectName} already exists and is not empty.`); + expect(mockExit).toHaveBeenCalledWith(1); + expect(fs.opendirSync).toHaveBeenCalledWith(projectPath); + expect(mockedDirHandle.closeSync).toHaveBeenCalled(); + }); + + it("should throw error if path is not writable (mkdir fails with EACCES)", async () => { + const cwd = "/test-unwritable" + const projectName = "my-app" + const projectPath = path.resolve(cwd, projectName) + + vi.mocked(prompts).mockResolvedValue({ type: "next", name: projectName }) + vi.mocked(fs.mkdirSync).mockImplementation((pathArg) => { + const resolvedArg = path.resolve(cwd, String(pathArg)); + if (resolvedArg === projectPath) { + const error: any = new Error("Permission denied") + error.code = "EACCES" + throw error + } + return undefined }) + await createProject({ cwd, force: false }) + + + expect(mockLoggerError).toHaveBeenCalledWith(`Path ${cwd} is not writable.`); expect(mockExit).toHaveBeenCalledWith(1) }) -}) + + + it('should proceed if project path already exists but is empty (EEXIST handled gracefully)', async () => { + const cwd = '/test'; + const projectName = 'empty-app'; + const projectPath = path.resolve(cwd, projectName); + + vi.mocked(prompts).mockResolvedValue({ type: 'next', name: projectName }); + + vi.mocked(fs.mkdirSync).mockImplementation((pathArg) => { + const resolvedArg = path.resolve(cwd, String(pathArg)); + if (resolvedArg === projectPath) { + const error: any = new Error('File already exists'); + error.code = 'EEXIST'; + throw error; + } + return undefined; + }); + + vi.mocked(fs.opendirSync).mockImplementation((pathArg) => { + const resolvedArg = path.resolve(cwd, String(pathArg)); + if (resolvedArg === projectPath) { + return { + readSync: vi.fn().mockReturnValue(null), + closeSync: vi.fn(), + } as any; + } + return { + readSync: vi.fn().mockReturnValue(null), + closeSync: vi.fn(), + } as any; + }); + + await createProject({ cwd, force: false }); + + expect(mockLoggerError).not.toHaveBeenCalled(); + expect(mockExit).not.toHaveBeenCalled(); + expect(fs.opendirSync).toHaveBeenCalledWith(projectPath); + }); +}) \ No newline at end of file diff --git a/packages/shadcn/src/utils/create-project.ts b/packages/shadcn/src/utils/create-project.ts index be3b5e46ffb..416dfe6e205 100644 --- a/packages/shadcn/src/utils/create-project.ts +++ b/packages/shadcn/src/utils/create-project.ts @@ -98,33 +98,77 @@ export async function createProject( withFallback: true, }) - const projectPath = `${options.cwd}/${projectName}` + const projectPath = path.resolve(options.cwd, projectName) // Check if path is writable. + // try { + // await fs.access(options.cwd, fs.constants.W_OK) + // } catch (error) { + // logger.break() + // logger.error(`The path ${highlighter.info(options.cwd)} is not writable.`) + // logger.error( + // `It is likely you do not have write permissions for this folder or the path ${highlighter.info( + // options.cwd + // )} does not exist.` + // ) + // logger.break() + // process.exit(1) + // } + + // if (fs.existsSync(path.resolve(options.cwd, projectName, "package.json"))) { + // logger.break() + // logger.error( + // `A project with the name ${highlighter.info(projectName)} already exists.` + // ) + // logger.error(`Please choose a different name and try again.`) + // logger.break() + // process.exit(1) + // } + + /** + * @fixed + * Attempt to create the project directory. + * - If it already exists, check if it is empty. + * - If not empty, show a clear error and exit. + * - If not writable or path does not exist, show a clear error and exit. + * - if exists but is empty, proceed with project creation. + * @Achieved + * 1. Proper writable permission check for the target directory. + * 2. This prevents cryptic errors when the target folder is not suitable. + */ try { - await fs.access(options.cwd, fs.constants.W_OK) - } catch (error) { - logger.break() - logger.error(`The path ${highlighter.info(options.cwd)} is not writable.`) - logger.error( - `It is likely you do not have write permissions for this folder or the path ${highlighter.info( - options.cwd - )} does not exist.` - ) - logger.break() - process.exit(1) - } + fs.mkdirSync(projectPath); + } catch (error: any) { + if (error.code === 'EEXIST') { + let dirHandle; + try { + dirHandle = fs.opendirSync(projectPath); + const entry = dirHandle.readSync(); + if (entry !== null) { + logger.error(`Directory ${projectName} already exists and is not empty.`); + process.exit(1); + } + } catch (readDirError: any) { + logger.error(`Cannot read or check directory ${highlighter.info(projectName)}: ${readDirError.message}`); + process.exit(1); + } finally { + if (dirHandle) { + dirHandle.closeSync(); + } + } + } else { + const msg = (error.code === 'EACCES' || error.code === 'EPERM') + ? `Path ${options.cwd} is not writable.` + : error.code === 'ENOENT' + ? `Path ${options.cwd} does not exist.` + : `Failed to create directory: ${error.message}`; - if (fs.existsSync(path.resolve(options.cwd, projectName, "package.json"))) { - logger.break() - logger.error( - `A project with the name ${highlighter.info(projectName)} already exists.` - ) - logger.error(`Please choose a different name and try again.`) - logger.break() - process.exit(1) + logger.error(msg); + process.exit(1); + } } + if (template === TEMPLATES.next) { await createNextProject(projectPath, { version: nextVersion, @@ -179,6 +223,7 @@ async function createNextProject( args.push("--turbopack") } + try { await execa( "npx", @@ -189,6 +234,7 @@ async function createNextProject( ) } catch (error) { logger.break() + logger.error(error) logger.error( `Something went wrong creating a new Next.js project. Please try again.` ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d3fe2850d1..7a342ef3658 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20847,4 +20847,4 @@ snapshots: zod@3.25.36: {} - zwitch@2.0.4: {} + zwitch@2.0.4: {} \ No newline at end of file