Skip to content
Merged
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
71 changes: 39 additions & 32 deletions src/NewProject.res
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down Expand Up @@ -144,58 +147,62 @@ 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()

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()

if templateName === Templates.resXTemplateName {
await synchronizeResxTemplateFiles(~projectName)
await synchronizeResxTemplateFiles(~projectName=packageName)
}

await RescriptVersions.installVersions(versions)
Expand All @@ -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 () => {
Expand All @@ -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
}
Expand All @@ -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)
},
Expand Down
62 changes: 62 additions & 0 deletions src/NewProjectLocation.res
Original file line number Diff line number Diff line change
@@ -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()
}
}
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
74 changes: 74 additions & 0 deletions test/NewProjectLocationTest.res
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading