Skip to content
Open
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
56 changes: 33 additions & 23 deletions src/NewProject.res
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down Expand Up @@ -111,20 +113,27 @@ 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()

if !CI.isRunningInCI {
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)
Expand All @@ -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 () => {
Expand All @@ -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
}
Expand All @@ -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)
},
Expand Down
58 changes: 58 additions & 0 deletions src/NewProjectLocation.res
Original file line number Diff line number Diff line change
@@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept './' as current-directory project name

The current-directory detection only matches the exact string ".", so a common equivalent input like create-rescript-app ./ --template vite is treated as a regular project name and then rejected by the package-name regex. This makes the new current-directory flow fail for a valid path form users often pass from shell completion/scripts; normalizing the argument (e.g., resolving ./ to .) before validation would avoid this regression.

Useful? React with 👍 / 👎.


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()
}
}
4 changes: 3 additions & 1 deletion src/bindings/Node.res
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
module Fs = {
@module("node:fs") external existsSync: string => bool = "existsSync"

@module("node:fs") external readdirSync: string => array<string> = "readdirSync"

@module("node:fs")
external readFileSync: (string, @as(json`"utf8"`) _) => string = "readFileSync"

Expand All @@ -14,7 +16,7 @@ module Fs = {
@module("node:fs") @scope("promises")
external appendFile: (string, string) => promise<unit> = "appendFile"

type cpOptions = {recursive?: bool}
type cpOptions = {recursive?: bool, force?: bool}

@module("node:fs") @scope("promises")
external copyFile: (string, string) => promise<unit> = "copyFile"
Expand Down
8 changes: 8 additions & 0 deletions test/CommandLineArgumentsTest.res
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
65 changes: 65 additions & 0 deletions test/NewProjectLocationTest.res
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading