diff --git a/src/NewProject.res b/src/NewProject.res index 25a50d9..1e9f004 100644 --- a/src/NewProject.res +++ b/src/NewProject.res @@ -2,19 +2,22 @@ open Node module P = ClackPrompts -let packageNameRegExp = /^[a-z0-9-]+$/ let resxTemplatePlaceholderName = "resx-template" -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 => @@ -144,41 +147,40 @@ let getPackageManagerName = (packageManager: PackageManagers.packageManager) => | Bun => "bun" } -let showGetStartedNote = async (~templateName, ~projectName) => { +let showGetStartedNote = async (~templateName, ~projectName, ~createInCurrentDirectory) => { + let changeDirectoryCommand = createInCurrentDirectory + ? "" + : `cd ${projectName} + +` + if templateName === Templates.resXTemplateName { let packageManagerInfo = await PackageManagers.getPackageManagerInfo() switch packageManagerInfo.packageManager { - | Bun => - P.note( - ~title="Get started", - ~message=`cd ${projectName} - -bun run dev`, - ) + | Bun => P.note(~title="Get started", ~message=`${changeDirectoryCommand}bun run dev`) | packageManager => P.note( ~title="Bun recommended", ~message=`This ResX template is Bun-centric. You created it with ${packageManager->getPackageManagerName}, but the generated project should use Bun. -cd ${projectName} -bun install +${changeDirectoryCommand}bun install bun run dev`, ) } } else { P.note( ~title="Get started", - ~message=`cd ${projectName} - -# See the project's README.md for more information.`, + ~message=`${changeDirectoryCommand}# See the project's README.md for more information.`, ) } } 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() @@ -186,16 +188,21 @@ 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() if templateName === Templates.resXTemplateName { - await synchronizeResxTemplateFiles(~projectName) + await synchronizeResxTemplateFiles(~projectName=packageName) } await RescriptVersions.installVersions(versions) @@ -205,7 +212,7 @@ let createProject = async (~templateName, ~projectName, ~versions) => { s->P.Spinner.stop("Project created.") } - await showGetStartedNote(~templateName, ~projectName) + await showGetStartedNote(~templateName, ~projectName, ~createInCurrentDirectory) } let createNewProject = async () => { @@ -228,7 +235,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 } @@ -239,7 +246,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..0535219 --- /dev/null +++ b/src/NewProjectLocation.res @@ -0,0 +1,62 @@ +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 => + switch projectName { + | "." | "./" => true + | _ => false + } + +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 1d4f027..bf7763a 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..a35d540 --- /dev/null +++ b/test/NewProjectLocationTest.res @@ -0,0 +1,74 @@ +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.test("accepts a trailing slash on the current directory argument", () => { + NewProjectLocation.getPackageName(~cwd="/tmp/my-app", "./")->Assert.strictEqual("my-app") + 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 + 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, + ) + 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() + }) +})