Skip to content

Commit 2264f5e

Browse files
Support creating projects in current directory (#128)
* Support creating projects in current directory * Accept trailing slash for current-directory projects --------- Signed-off-by: Florian Hammerschmidt <florianh89@gmail.com>
1 parent 1e562cb commit 2264f5e

5 files changed

Lines changed: 186 additions & 33 deletions

File tree

‎src/NewProject.res‎

Lines changed: 39 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,22 @@ open Node
22

33
module P = ClackPrompts
44

5-
let packageNameRegExp = /^[a-z0-9-]+$/
65
let resxTemplatePlaceholderName = "resx-template"
76

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

1922
let updatePackageJson = async (~projectName, ~versions) =>
2023
await JsonUtils.updateJsonFile("package.json", json =>
@@ -144,58 +147,62 @@ let getPackageManagerName = (packageManager: PackageManagers.packageManager) =>
144147
| Bun => "bun"
145148
}
146149

147-
let showGetStartedNote = async (~templateName, ~projectName) => {
150+
let showGetStartedNote = async (~templateName, ~projectName, ~createInCurrentDirectory) => {
151+
let changeDirectoryCommand = createInCurrentDirectory
152+
? ""
153+
: `cd ${projectName}
154+
155+
`
156+
148157
if templateName === Templates.resXTemplateName {
149158
let packageManagerInfo = await PackageManagers.getPackageManagerInfo()
150159

151160
switch packageManagerInfo.packageManager {
152-
| Bun =>
153-
P.note(
154-
~title="Get started",
155-
~message=`cd ${projectName}
156-
157-
bun run dev`,
158-
)
161+
| Bun => P.note(~title="Get started", ~message=`${changeDirectoryCommand}bun run dev`)
159162
| packageManager =>
160163
P.note(
161164
~title="Bun recommended",
162165
~message=`This ResX template is Bun-centric. You created it with ${packageManager->getPackageManagerName}, but the generated project should use Bun.
163166
164-
cd ${projectName}
165-
bun install
167+
${changeDirectoryCommand}bun install
166168
bun run dev`,
167169
)
168170
}
169171
} else {
170172
P.note(
171173
~title="Get started",
172-
~message=`cd ${projectName}
173-
174-
# See the project's README.md for more information.`,
174+
~message=`${changeDirectoryCommand}# See the project's README.md for more information.`,
175175
)
176176
}
177177
}
178178

179179
let createProject = async (~templateName, ~projectName, ~versions) => {
180180
let templatePath = CraPaths.getTemplatePath(~templateName)
181-
let projectPath = Path.join2(Process.cwd(), projectName)
181+
let packageName = NewProjectLocation.getPackageName(projectName)
182+
let projectPath = NewProjectLocation.getProjectPath(projectName)
183+
let createInCurrentDirectory = NewProjectLocation.isCurrentDirectoryProject(projectName)
182184

183185
let s = P.spinner()
184186

185187
if !CI.isRunningInCI {
186188
s->P.Spinner.start("Creating project...")
187189
}
188190

189-
await Fs.Promises.cp(templatePath, projectPath, ~options={recursive: true})
191+
if createInCurrentDirectory {
192+
await Fs.Promises.cp(templatePath, projectPath, ~options={recursive: true, force: false})
193+
} else {
194+
await Fs.Promises.cp(templatePath, projectPath, ~options={recursive: true})
195+
}
196+
190197
Process.chdir(projectPath)
191198

192-
await Fs.Promises.rename("_gitignore", ".gitignore")
193-
await updatePackageJson(~projectName, ~versions)
194-
await updateRescriptJson(~projectName, ~versions)
199+
await installGitignore()
200+
await updatePackageJson(~projectName=packageName, ~versions)
201+
await updateRescriptJson(~projectName=packageName, ~versions)
195202
await updateViteConfig()
196203

197204
if templateName === Templates.resXTemplateName {
198-
await synchronizeResxTemplateFiles(~projectName)
205+
await synchronizeResxTemplateFiles(~projectName=packageName)
199206
}
200207

201208
await RescriptVersions.installVersions(versions)
@@ -205,7 +212,7 @@ let createProject = async (~templateName, ~projectName, ~versions) => {
205212
s->P.Spinner.stop("Project created.")
206213
}
207214

208-
await showGetStartedNote(~templateName, ~projectName)
215+
await showGetStartedNote(~templateName, ~projectName, ~createInCurrentDirectory)
209216
}
210217

211218
let createNewProject = async () => {
@@ -228,7 +235,7 @@ let createNewProject = async () => {
228235
let projectName = switch commandLineArguments.projectName {
229236
| Some(projectName) if useDefaultVersions =>
230237
// Note this throws in the some case, which is why we cannot use Option.getOrThrow here.
231-
switch validateProjectName(projectName) {
238+
switch NewProjectLocation.validateProjectName(projectName) {
232239
| Error(message) => JsError.throwWithMessage(message)
233240
| Ok() => projectName
234241
}
@@ -239,7 +246,7 @@ let createNewProject = async () => {
239246
placeholder: "my-rescript-app",
240247
?initialValue,
241248
validate: projectName =>
242-
switch validateProjectName(projectName) {
249+
switch NewProjectLocation.validateProjectName(projectName) {
243250
| Ok() => None
244251
| Error(error) => Some(error)
245252
},

‎src/NewProjectLocation.res‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
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 =>
19+
switch projectName {
20+
| "." | "./" => true
21+
| _ => false
22+
}
23+
24+
let getPackageName = (~cwd=Process.cwd(), projectName) =>
25+
isCurrentDirectoryProject(projectName) ? Path.basename(cwd) : projectName
26+
27+
let getProjectPath = (~cwd=Process.cwd(), projectName) =>
28+
isCurrentDirectoryProject(projectName) ? cwd : Path.join2(cwd, projectName)
29+
30+
let isAllowedCurrentDirectoryEntry = entry => {
31+
let normalizedEntry = entry->String.toLowerCase
32+
33+
allowedCurrentDirectoryEntries
34+
->Array.find(allowedEntry => allowedEntry === normalizedEntry)
35+
->Option.isSome
36+
}
37+
38+
let validateCurrentDirectory = cwd => {
39+
let disallowedEntries =
40+
Fs.readdirSync(cwd)->Array.filter(entry => !(entry->isAllowedCurrentDirectoryEntry))
41+
42+
switch disallowedEntries {
43+
| [] => Ok()
44+
| _ => Error("The current directory contains files that could conflict with project creation.")
45+
}
46+
}
47+
48+
let validateProjectName = (~cwd=Process.cwd(), projectName) => {
49+
let packageName = getPackageName(~cwd, projectName)
50+
51+
if packageName->String.trim->String.length === 0 {
52+
Error("Project name must not be empty.")
53+
} else if !(packageNameRegExp->RegExp.test(packageName)) {
54+
Error("Project name may only contain lower case letters, numbers and hyphens.")
55+
} else if isCurrentDirectoryProject(projectName) {
56+
validateCurrentDirectory(cwd)
57+
} else if Fs.existsSync(getProjectPath(~cwd, projectName)) {
58+
Error(`The folder ${projectName} already exist in the current directory.`)
59+
} else {
60+
Ok()
61+
}
62+
}

‎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: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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.test("accepts a trailing slash on the current directory argument", () => {
38+
NewProjectLocation.getPackageName(~cwd="/tmp/my-app", "./")->Assert.strictEqual("my-app")
39+
NewProjectLocation.getProjectPath(~cwd="/tmp/my-app", "./")->Assert.strictEqual("/tmp/my-app")
40+
})
41+
42+
Test.testAsync("allows creating in a repository with README and license files", async () => {
43+
let projectPath = await resetTestRoot("my-app")
44+
await Fs.Promises.mkdir(Path.join2(projectPath, ".git"))
45+
await Fs.Promises.writeFile(Path.join2(projectPath, "README.md"), "")
46+
await Fs.Promises.writeFile(Path.join2(projectPath, "LICENSE"), "")
47+
48+
NewProjectLocation.validateProjectName(~cwd=projectPath, ".")->assertValidationOk
49+
NewProjectLocation.validateProjectName(~cwd=projectPath, "./")->assertValidationOk
50+
await cleanupTestRoot()
51+
})
52+
53+
Test.testAsync("rejects creating in a current directory with project files", async () => {
54+
let projectPath = await resetTestRoot("my-app")
55+
await Fs.Promises.writeFile(Path.join2(projectPath, "src"), "")
56+
57+
NewProjectLocation.validateProjectName(~cwd=projectPath, ".")->assertValidationError(
58+
currentDirectoryConflictMessage,
59+
)
60+
NewProjectLocation.validateProjectName(~cwd=projectPath, "./")->assertValidationError(
61+
currentDirectoryConflictMessage,
62+
)
63+
await cleanupTestRoot()
64+
})
65+
66+
Test.testAsync("rejects creating a nested project that already exists", async () => {
67+
let _ = await resetTestRoot("existing-app")
68+
69+
NewProjectLocation.validateProjectName(~cwd=testRoot, "existing-app")->assertValidationError(
70+
"The folder existing-app already exist in the current directory.",
71+
)
72+
await cleanupTestRoot()
73+
})
74+
})

0 commit comments

Comments
 (0)