diff --git a/src/NewProject.res b/src/NewProject.res index 37848bb..d4a9c32 100644 --- a/src/NewProject.res +++ b/src/NewProject.res @@ -2,18 +2,20 @@ open Node module P = ClackPrompts -let packageNameRegExp = /^[a-z0-9-]+$/ - -let validateProjectName = projectName => - if projectName->String.trim->String.length === 0 { - Error("Project name must not be empty.") - } else if !(packageNameRegExp->RegExp.test(projectName)) { - Error("Project name may only contain lower case letters, numbers and hyphens.") - } else if Fs.existsSync(Path.join2(Process.cwd(), projectName)) { - Error(`The folder ${projectName} already exist in the current directory.`) - } else { - Ok() +let installGitignore = async () => { + let templateGitignorePath = "_gitignore" + let gitignorePath = ".gitignore" + + if Fs.existsSync(templateGitignorePath) { + if Fs.existsSync(gitignorePath) { + let templateGitignore = await Fs.Promises.readFile(templateGitignorePath) + await Fs.Promises.appendFile(gitignorePath, `${Os.eol}${templateGitignore}`) + await Fs.Promises.rm(templateGitignorePath, ~options={force: true}) + } else { + await Fs.Promises.rename(templateGitignorePath, gitignorePath) + } } +} let updatePackageJson = async (~projectName, ~versions) => await JsonUtils.updateJsonFile("package.json", json => @@ -111,7 +113,9 @@ let promptTemplateName = async () => { let createProject = async (~templateName, ~projectName, ~versions) => { let templatePath = CraPaths.getTemplatePath(~templateName) - let projectPath = Path.join2(Process.cwd(), projectName) + let packageName = NewProjectLocation.getPackageName(projectName) + let projectPath = NewProjectLocation.getProjectPath(projectName) + let createInCurrentDirectory = NewProjectLocation.isCurrentDirectoryProject(projectName) let s = P.spinner() @@ -119,12 +123,17 @@ let createProject = async (~templateName, ~projectName, ~versions) => { s->P.Spinner.start("Creating project...") } - await Fs.Promises.cp(templatePath, projectPath, ~options={recursive: true}) + if createInCurrentDirectory { + await Fs.Promises.cp(templatePath, projectPath, ~options={recursive: true, force: false}) + } else { + await Fs.Promises.cp(templatePath, projectPath, ~options={recursive: true}) + } + Process.chdir(projectPath) - await Fs.Promises.rename("_gitignore", ".gitignore") - await updatePackageJson(~projectName, ~versions) - await updateRescriptJson(~projectName, ~versions) + await installGitignore() + await updatePackageJson(~projectName=packageName, ~versions) + await updateRescriptJson(~projectName=packageName, ~versions) await updateViteConfig() await RescriptVersions.installVersions(versions) @@ -134,12 +143,13 @@ let createProject = async (~templateName, ~projectName, ~versions) => { s->P.Spinner.stop("Project created.") } - P.note( - ~title="Get started", - ~message=`cd ${projectName} + let getStartedMessage = createInCurrentDirectory + ? "# See the project's README.md for more information." + : `cd ${projectName} -# See the project's README.md for more information.`, - ) +# See the project's README.md for more information.` + + P.note(~title="Get started", ~message=getStartedMessage) } let createNewProject = async () => { @@ -159,7 +169,7 @@ let createNewProject = async () => { let projectName = switch commandLineArguments.projectName { | Some(projectName) if useDefaultVersions => // Note this throws in the some case, which is why we cannot use Option.getOrThrow here. - switch validateProjectName(projectName) { + switch NewProjectLocation.validateProjectName(projectName) { | Error(message) => JsError.throwWithMessage(message) | Ok() => projectName } @@ -170,7 +180,7 @@ let createNewProject = async () => { placeholder: "my-rescript-app", ?initialValue, validate: projectName => - switch validateProjectName(projectName) { + switch NewProjectLocation.validateProjectName(projectName) { | Ok() => None | Error(error) => Some(error) }, diff --git a/src/NewProjectLocation.res b/src/NewProjectLocation.res new file mode 100644 index 0000000..b8a2527 --- /dev/null +++ b/src/NewProjectLocation.res @@ -0,0 +1,58 @@ +open Node + +let currentDirectoryArgument = "." +let packageNameRegExp = /^[a-z0-9-]+$/ + +let allowedCurrentDirectoryEntries = [ + ".git", + ".gitattributes", + ".gitignore", + "licence", + "licence.md", + "license", + "license.md", + "readme", + "readme.md", +] + +let isCurrentDirectoryProject = projectName => projectName === currentDirectoryArgument + +let getPackageName = (~cwd=Process.cwd(), projectName) => + isCurrentDirectoryProject(projectName) ? Path.basename(cwd) : projectName + +let getProjectPath = (~cwd=Process.cwd(), projectName) => + isCurrentDirectoryProject(projectName) ? cwd : Path.join2(cwd, projectName) + +let isAllowedCurrentDirectoryEntry = entry => { + let normalizedEntry = entry->String.toLowerCase + + allowedCurrentDirectoryEntries + ->Array.find(allowedEntry => allowedEntry === normalizedEntry) + ->Option.isSome +} + +let validateCurrentDirectory = cwd => { + let disallowedEntries = + Fs.readdirSync(cwd)->Array.filter(entry => !(entry->isAllowedCurrentDirectoryEntry)) + + switch disallowedEntries { + | [] => Ok() + | _ => Error("The current directory contains files that could conflict with project creation.") + } +} + +let validateProjectName = (~cwd=Process.cwd(), projectName) => { + let packageName = getPackageName(~cwd, projectName) + + if packageName->String.trim->String.length === 0 { + Error("Project name must not be empty.") + } else if !(packageNameRegExp->RegExp.test(packageName)) { + Error("Project name may only contain lower case letters, numbers and hyphens.") + } else if isCurrentDirectoryProject(projectName) { + validateCurrentDirectory(cwd) + } else if Fs.existsSync(getProjectPath(~cwd, projectName)) { + Error(`The folder ${projectName} already exist in the current directory.`) + } else { + Ok() + } +} diff --git a/src/bindings/Node.res b/src/bindings/Node.res index d3519b8..44a7f9a 100644 --- a/src/bindings/Node.res +++ b/src/bindings/Node.res @@ -1,6 +1,8 @@ module Fs = { @module("node:fs") external existsSync: string => bool = "existsSync" + @module("node:fs") external readdirSync: string => array = "readdirSync" + @module("node:fs") external readFileSync: (string, @as(json`"utf8"`) _) => string = "readFileSync" @@ -14,7 +16,7 @@ module Fs = { @module("node:fs") @scope("promises") external appendFile: (string, string) => promise = "appendFile" - type cpOptions = {recursive?: bool} + type cpOptions = {recursive?: bool, force?: bool} @module("node:fs") @scope("promises") external copyFile: (string, string) => promise = "copyFile" diff --git a/test/CommandLineArgumentsTest.res b/test/CommandLineArgumentsTest.res index ccc0645..e594c77 100644 --- a/test/CommandLineArgumentsTest.res +++ b/test/CommandLineArgumentsTest.res @@ -31,6 +31,14 @@ Test.describe("CommandLineArguments", () => { } }) + Test.test("parses the current directory as a project name", () => { + switch CommandLineArguments.parse(list{"."}) { + | Ok(commandLineArguments) => + commandLineArguments->assertCommandLineArguments(~projectName=Some("."), ~templateName=None) + | Error(message) => Assert.fail(message) + } + }) + Test.test("parses the template name from the -t flag", () => { switch CommandLineArguments.parse(list{"my-app", "-t", "vite"}) { | Ok(commandLineArguments) => diff --git a/test/NewProjectLocationTest.res b/test/NewProjectLocationTest.res new file mode 100644 index 0000000..a476411 --- /dev/null +++ b/test/NewProjectLocationTest.res @@ -0,0 +1,65 @@ +open Node + +let testRoot = Path.join2(Process.cwd(), ".tmp-new-project-location-test") +let currentDirectoryConflictMessage = "The current directory contains files that could conflict with project creation." + +let cleanupTestRoot = async () => + await Fs.Promises.rm(testRoot, ~options={recursive: true, force: true}) + +let resetTestRoot = async projectDirectoryName => { + await cleanupTestRoot() + let projectPath = Path.join2(testRoot, projectDirectoryName) + await Fs.Promises.mkdir(projectPath, ~options={recursive: true}) + projectPath +} + +let assertValidationOk = result => + switch result { + | Ok() => () + | Error(message) => Assert.fail(`Expected project name to be valid, got: ${message}`) + } + +let assertValidationError = (result, expectedMessage) => + switch result { + | Error(message) => Assert.strictEqual(message, expectedMessage) + | Ok() => Assert.fail(`Expected validation error: ${expectedMessage}`) + } + +Test.describe("NewProjectLocation", () => { + Test.test("uses the current directory basename as the package name", () => { + NewProjectLocation.getPackageName(~cwd="/tmp/my-app", ".")->Assert.strictEqual("my-app") + }) + + Test.test("uses the current directory as the project path", () => { + NewProjectLocation.getProjectPath(~cwd="/tmp/my-app", ".")->Assert.strictEqual("/tmp/my-app") + }) + + Test.testAsync("allows creating in a repository with README and license files", async () => { + let projectPath = await resetTestRoot("my-app") + await Fs.Promises.mkdir(Path.join2(projectPath, ".git")) + await Fs.Promises.writeFile(Path.join2(projectPath, "README.md"), "") + await Fs.Promises.writeFile(Path.join2(projectPath, "LICENSE"), "") + + NewProjectLocation.validateProjectName(~cwd=projectPath, ".")->assertValidationOk + await cleanupTestRoot() + }) + + Test.testAsync("rejects creating in a current directory with project files", async () => { + let projectPath = await resetTestRoot("my-app") + await Fs.Promises.writeFile(Path.join2(projectPath, "src"), "") + + NewProjectLocation.validateProjectName(~cwd=projectPath, ".")->assertValidationError( + currentDirectoryConflictMessage, + ) + await cleanupTestRoot() + }) + + Test.testAsync("rejects creating a nested project that already exists", async () => { + let _ = await resetTestRoot("existing-app") + + NewProjectLocation.validateProjectName(~cwd=testRoot, "existing-app")->assertValidationError( + "The folder existing-app already exist in the current directory.", + ) + await cleanupTestRoot() + }) +})