diff --git a/src/NewProjectLocation.res b/src/NewProjectLocation.res index 0535219..fe2f0a5 100644 --- a/src/NewProjectLocation.res +++ b/src/NewProjectLocation.res @@ -2,6 +2,7 @@ open Node let currentDirectoryArgument = "." let packageNameRegExp = /^[a-z0-9-]+$/ +let devcontainerDirectoryName = ".devcontainer" let allowedCurrentDirectoryEntries = [ ".git", @@ -27,17 +28,38 @@ let getPackageName = (~cwd=Process.cwd(), projectName) => let getProjectPath = (~cwd=Process.cwd(), projectName) => isCurrentDirectoryProject(projectName) ? cwd : Path.join2(cwd, projectName) -let isAllowedCurrentDirectoryEntry = entry => { +let hasDevcontainerDirectory = projectPath => + try { + Path.join2(projectPath, devcontainerDirectoryName)->Fs.statSync->Fs.Stats.isDirectory + } catch { + | Exn.Error(_) => false + } + +let containsOnlyDevcontainerDirectory = projectPath => + try { + switch Fs.readdirSync(projectPath) { + | [entry] if entry === devcontainerDirectoryName => hasDevcontainerDirectory(projectPath) + | _ => false + } + } catch { + | Exn.Error(_) => false + } + +let isAllowedCurrentDirectoryEntry = (~cwd, entry) => { let normalizedEntry = entry->String.toLowerCase - allowedCurrentDirectoryEntries - ->Array.find(allowedEntry => allowedEntry === normalizedEntry) - ->Option.isSome + switch entry { + | ".devcontainer" => hasDevcontainerDirectory(cwd) + | _ => + allowedCurrentDirectoryEntries + ->Array.find(allowedEntry => allowedEntry === normalizedEntry) + ->Option.isSome + } } let validateCurrentDirectory = cwd => { let disallowedEntries = - Fs.readdirSync(cwd)->Array.filter(entry => !(entry->isAllowedCurrentDirectoryEntry)) + Fs.readdirSync(cwd)->Array.filter(entry => !isAllowedCurrentDirectoryEntry(~cwd, entry)) switch disallowedEntries { | [] => Ok() @@ -54,7 +76,10 @@ let validateProjectName = (~cwd=Process.cwd(), projectName) => { 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)) { + } else if ( + Fs.existsSync(getProjectPath(~cwd, projectName)) && + !(getProjectPath(~cwd, projectName)->containsOnlyDevcontainerDirectory) + ) { 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 44a7f9a..893e7da 100644 --- a/src/bindings/Node.res +++ b/src/bindings/Node.res @@ -6,6 +6,14 @@ module Fs = { @module("node:fs") external readFileSync: (string, @as(json`"utf8"`) _) => string = "readFileSync" + type stats + + @module("node:fs") external statSync: string => stats = "statSync" + + module Stats = { + @send external isDirectory: stats => bool = "isDirectory" + } + module Promises = { @module("node:fs") @scope("promises") external readFile: (string, @as(json`"utf8"`) _) => promise = "readFile" diff --git a/test/NewProjectLocationTest.res b/test/NewProjectLocationTest.res index a35d540..4a5a182 100644 --- a/test/NewProjectLocationTest.res +++ b/test/NewProjectLocationTest.res @@ -71,4 +71,50 @@ Test.describe("NewProjectLocation", () => { ) await cleanupTestRoot() }) + + Test.testAsync("allows devcontainer-only project directories", async () => { + let projectPath = await resetTestRoot("my-app") + await Fs.Promises.mkdir(Path.join2(projectPath, ".devcontainer")) + + NewProjectLocation.validateProjectName(~cwd=testRoot, "my-app")->assertValidationOk + NewProjectLocation.validateProjectName(~cwd=projectPath, ".")->assertValidationOk + NewProjectLocation.validateProjectName(~cwd=projectPath, "./")->assertValidationOk + + await Fs.Promises.writeFile(Path.join2(projectPath, "README.md"), "") + NewProjectLocation.validateProjectName(~cwd=projectPath, ".")->assertValidationOk + await cleanupTestRoot() + }) + + Test.testAsync("rejects devcontainer directories with conflicting project files", async () => { + let projectPath = await resetTestRoot("my-app") + await Fs.Promises.mkdir(Path.join2(projectPath, ".devcontainer")) + await Fs.Promises.writeFile(Path.join2(projectPath, "package.json"), "{}") + + NewProjectLocation.validateProjectName(~cwd=testRoot, "my-app")->assertValidationError( + "The folder my-app already exist in the current directory.", + ) + NewProjectLocation.validateProjectName(~cwd=projectPath, ".")->assertValidationError( + currentDirectoryConflictMessage, + ) + NewProjectLocation.validateProjectName(~cwd=projectPath, "./")->assertValidationError( + currentDirectoryConflictMessage, + ) + await cleanupTestRoot() + }) + + Test.testAsync("rejects a .devcontainer file", async () => { + let projectPath = await resetTestRoot("my-app") + await Fs.Promises.writeFile(Path.join2(projectPath, ".devcontainer"), "") + + NewProjectLocation.validateProjectName(~cwd=testRoot, "my-app")->assertValidationError( + "The folder my-app already exist in the current directory.", + ) + NewProjectLocation.validateProjectName(~cwd=projectPath, ".")->assertValidationError( + currentDirectoryConflictMessage, + ) + NewProjectLocation.validateProjectName(~cwd=projectPath, "./")->assertValidationError( + currentDirectoryConflictMessage, + ) + await cleanupTestRoot() + }) })