Skip to content

Commit fb4dd7a

Browse files
Support creating projects in current directory
1 parent ea6af06 commit fb4dd7a

5 files changed

Lines changed: 167 additions & 24 deletions

File tree

src/NewProject.res

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,20 @@ open Node
22

33
module P = ClackPrompts
44

5-
let packageNameRegExp = /^[a-z0-9-]+$/
6-
7-
let validateProjectName = projectName =>
8-
if projectName->String.trim->String.length === 0 {
9-
Error("Project name must not be empty.")
10-
} else if !(packageNameRegExp->RegExp.test(projectName)) {
11-
Error("Project name may only contain lower case letters, numbers and hyphens.")
12-
} else if Fs.existsSync(Path.join2(Process.cwd(), projectName)) {
13-
Error(`The folder ${projectName} already exist in the current directory.`)
14-
} else {
15-
Ok()
5+
let installGitignore = async () => {
6+
let templateGitignorePath = "_gitignore"
7+
let gitignorePath = ".gitignore"
8+
9+
if Fs.existsSync(templateGitignorePath) {
10+
if Fs.existsSync(gitignorePath) {
11+
let templateGitignore = await Fs.Promises.readFile(templateGitignorePath)
12+
await Fs.Promises.appendFile(gitignorePath, `${Os.eol}${templateGitignore}`)
13+
await Fs.Promises.rm(templateGitignorePath, ~options={force: true})
14+
} else {
15+
await Fs.Promises.rename(templateGitignorePath, gitignorePath)
16+
}
1617
}
18+
}
1719

1820
let updatePackageJson = async (~projectName, ~versions) =>
1921
await JsonUtils.updateJsonFile("package.json", json =>
@@ -111,20 +113,27 @@ let promptTemplateName = async () => {
111113

112114
let createProject = async (~templateName, ~projectName, ~versions) => {
113115
let templatePath = CraPaths.getTemplatePath(~templateName)
114-
let projectPath = Path.join2(Process.cwd(), projectName)
116+
let packageName = NewProjectLocation.getPackageName(projectName)
117+
let projectPath = NewProjectLocation.getProjectPath(projectName)
118+
let createInCurrentDirectory = NewProjectLocation.isCurrentDirectoryProject(projectName)
115119

116120
let s = P.spinner()
117121

118122
if !CI.isRunningInCI {
119123
s->P.Spinner.start("Creating project...")
120124
}
121125

122-
await Fs.Promises.cp(templatePath, projectPath, ~options={recursive: true})
126+
if createInCurrentDirectory {
127+
await Fs.Promises.cp(templatePath, projectPath, ~options={recursive: true, force: false})
128+
} else {
129+
await Fs.Promises.cp(templatePath, projectPath, ~options={recursive: true})
130+
}
131+
123132
Process.chdir(projectPath)
124133

125-
await Fs.Promises.rename("_gitignore", ".gitignore")
126-
await updatePackageJson(~projectName, ~versions)
127-
await updateRescriptJson(~projectName, ~versions)
134+
await installGitignore()
135+
await updatePackageJson(~projectName=packageName, ~versions)
136+
await updateRescriptJson(~projectName=packageName, ~versions)
128137
await updateViteConfig()
129138

130139
await RescriptVersions.installVersions(versions)
@@ -134,12 +143,13 @@ let createProject = async (~templateName, ~projectName, ~versions) => {
134143
s->P.Spinner.stop("Project created.")
135144
}
136145

137-
P.note(
138-
~title="Get started",
139-
~message=`cd ${projectName}
146+
let getStartedMessage = createInCurrentDirectory
147+
? "# See the project's README.md for more information."
148+
: `cd ${projectName}
140149
141-
# See the project's README.md for more information.`,
142-
)
150+
# See the project's README.md for more information.`
151+
152+
P.note(~title="Get started", ~message=getStartedMessage)
143153
}
144154

145155
let createNewProject = async () => {
@@ -159,7 +169,7 @@ let createNewProject = async () => {
159169
let projectName = switch commandLineArguments.projectName {
160170
| Some(projectName) if useDefaultVersions =>
161171
// Note this throws in the some case, which is why we cannot use Option.getOrThrow here.
162-
switch validateProjectName(projectName) {
172+
switch NewProjectLocation.validateProjectName(projectName) {
163173
| Error(message) => JsError.throwWithMessage(message)
164174
| Ok() => projectName
165175
}
@@ -170,7 +180,7 @@ let createNewProject = async () => {
170180
placeholder: "my-rescript-app",
171181
?initialValue,
172182
validate: projectName =>
173-
switch validateProjectName(projectName) {
183+
switch NewProjectLocation.validateProjectName(projectName) {
174184
| Ok() => None
175185
| Error(error) => Some(error)
176186
},

src/NewProjectLocation.res

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
open Node
2+
3+
let currentDirectoryArgument = "."
4+
let packageNameRegExp = /^[a-z0-9-]+$/
5+
6+
let allowedCurrentDirectoryEntries = [
7+
".git",
8+
".gitattributes",
9+
".gitignore",
10+
"licence",
11+
"licence.md",
12+
"license",
13+
"license.md",
14+
"readme",
15+
"readme.md",
16+
]
17+
18+
let isCurrentDirectoryProject = projectName => projectName === currentDirectoryArgument
19+
20+
let getPackageName = (~cwd=Process.cwd(), projectName) =>
21+
isCurrentDirectoryProject(projectName) ? Path.basename(cwd) : projectName
22+
23+
let getProjectPath = (~cwd=Process.cwd(), projectName) =>
24+
isCurrentDirectoryProject(projectName) ? cwd : Path.join2(cwd, projectName)
25+
26+
let isAllowedCurrentDirectoryEntry = entry => {
27+
let normalizedEntry = entry->String.toLowerCase
28+
29+
allowedCurrentDirectoryEntries
30+
->Array.find(allowedEntry => allowedEntry === normalizedEntry)
31+
->Option.isSome
32+
}
33+
34+
let validateCurrentDirectory = cwd => {
35+
let disallowedEntries =
36+
Fs.readdirSync(cwd)->Array.filter(entry => !(entry->isAllowedCurrentDirectoryEntry))
37+
38+
switch disallowedEntries {
39+
| [] => Ok()
40+
| _ => Error("The current directory contains files that could conflict with project creation.")
41+
}
42+
}
43+
44+
let validateProjectName = (~cwd=Process.cwd(), projectName) => {
45+
let packageName = getPackageName(~cwd, projectName)
46+
47+
if packageName->String.trim->String.length === 0 {
48+
Error("Project name must not be empty.")
49+
} else if !(packageNameRegExp->RegExp.test(packageName)) {
50+
Error("Project name may only contain lower case letters, numbers and hyphens.")
51+
} else if isCurrentDirectoryProject(projectName) {
52+
validateCurrentDirectory(cwd)
53+
} else if Fs.existsSync(getProjectPath(~cwd, projectName)) {
54+
Error(`The folder ${projectName} already exist in the current directory.`)
55+
} else {
56+
Ok()
57+
}
58+
}

src/bindings/Node.res

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
module Fs = {
22
@module("node:fs") external existsSync: string => bool = "existsSync"
33

4+
@module("node:fs") external readdirSync: string => array<string> = "readdirSync"
5+
46
@module("node:fs")
57
external readFileSync: (string, @as(json`"utf8"`) _) => string = "readFileSync"
68

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

17-
type cpOptions = {recursive?: bool}
19+
type cpOptions = {recursive?: bool, force?: bool}
1820

1921
@module("node:fs") @scope("promises")
2022
external copyFile: (string, string) => promise<unit> = "copyFile"

test/CommandLineArgumentsTest.res

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ Test.describe("CommandLineArguments", () => {
3131
}
3232
})
3333

34+
Test.test("parses the current directory as a project name", () => {
35+
switch CommandLineArguments.parse(list{"."}) {
36+
| Ok(commandLineArguments) =>
37+
commandLineArguments->assertCommandLineArguments(~projectName=Some("."), ~templateName=None)
38+
| Error(message) => Assert.fail(message)
39+
}
40+
})
41+
3442
Test.test("parses the template name from the -t flag", () => {
3543
switch CommandLineArguments.parse(list{"my-app", "-t", "vite"}) {
3644
| Ok(commandLineArguments) =>

test/NewProjectLocationTest.res

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
open Node
2+
3+
let testRoot = Path.join2(Process.cwd(), ".tmp-new-project-location-test")
4+
let currentDirectoryConflictMessage = "The current directory contains files that could conflict with project creation."
5+
6+
let cleanupTestRoot = async () =>
7+
await Fs.Promises.rm(testRoot, ~options={recursive: true, force: true})
8+
9+
let resetTestRoot = async projectDirectoryName => {
10+
await cleanupTestRoot()
11+
let projectPath = Path.join2(testRoot, projectDirectoryName)
12+
await Fs.Promises.mkdir(projectPath, ~options={recursive: true})
13+
projectPath
14+
}
15+
16+
let assertValidationOk = result =>
17+
switch result {
18+
| Ok() => ()
19+
| Error(message) => Assert.fail(`Expected project name to be valid, got: ${message}`)
20+
}
21+
22+
let assertValidationError = (result, expectedMessage) =>
23+
switch result {
24+
| Error(message) => Assert.strictEqual(message, expectedMessage)
25+
| Ok() => Assert.fail(`Expected validation error: ${expectedMessage}`)
26+
}
27+
28+
Test.describe("NewProjectLocation", () => {
29+
Test.test("uses the current directory basename as the package name", () => {
30+
NewProjectLocation.getPackageName(~cwd="/tmp/my-app", ".")->Assert.strictEqual("my-app")
31+
})
32+
33+
Test.test("uses the current directory as the project path", () => {
34+
NewProjectLocation.getProjectPath(~cwd="/tmp/my-app", ".")->Assert.strictEqual("/tmp/my-app")
35+
})
36+
37+
Test.testAsync("allows creating in a repository with README and license files", async () => {
38+
let projectPath = await resetTestRoot("my-app")
39+
await Fs.Promises.mkdir(Path.join2(projectPath, ".git"))
40+
await Fs.Promises.writeFile(Path.join2(projectPath, "README.md"), "")
41+
await Fs.Promises.writeFile(Path.join2(projectPath, "LICENSE"), "")
42+
43+
NewProjectLocation.validateProjectName(~cwd=projectPath, ".")->assertValidationOk
44+
await cleanupTestRoot()
45+
})
46+
47+
Test.testAsync("rejects creating in a current directory with project files", async () => {
48+
let projectPath = await resetTestRoot("my-app")
49+
await Fs.Promises.writeFile(Path.join2(projectPath, "src"), "")
50+
51+
NewProjectLocation.validateProjectName(~cwd=projectPath, ".")->assertValidationError(
52+
currentDirectoryConflictMessage,
53+
)
54+
await cleanupTestRoot()
55+
})
56+
57+
Test.testAsync("rejects creating a nested project that already exists", async () => {
58+
let _ = await resetTestRoot("existing-app")
59+
60+
NewProjectLocation.validateProjectName(~cwd=testRoot, "existing-app")->assertValidationError(
61+
"The folder existing-app already exist in the current directory.",
62+
)
63+
await cleanupTestRoot()
64+
})
65+
})

0 commit comments

Comments
 (0)