Repository: dbt-labs/dbt-core

Issue:
[CT-61] [Bug] interactive dbt init allows illegal project names
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Current Behavior

I just set up a sandbox project for myself using the new interactive dbt-init. When it asked what I wanted to call it, I chose `joel-sandbox`. 

When I ran `dbt debug`, it failed because ` 'joel-sandbox' does not match '^[^\\d\\W]\\w*$'`. 



### Expected Behavior

`dbt init` should at minimum include the "lowercase and underscores only" rule, and really should also validate that the input conforms. 

### Steps To Reproduce

_No response_

### Relevant log output

```shell
Project loading failed for the following reason:
Runtime Error
  at path ['name']: 'joel-sandbox' does not match '^[^\\d\\W]\\w*$'

Error encountered in /Users/joel/Documents/GitHub/joel-sandbox/dbt_project.yml
```


### Environment

```markdown
- OS: MacOS
- Python: 3.8.10
- dbt: 1.0.0
```


### What database are you using dbt with?

snowflake

### Additional Context

_No response_


Discussion:
Thanks for the bug report @joellabes!  We'll get this sorted ASAP.

Resolving patch:
diff --git a/core/dbt/contracts/project.py b/core/dbt/contracts/project.py
--- a/core/dbt/contracts/project.py
+++ b/core/dbt/contracts/project.py
@@ -18,6 +18,18 @@
 class Name(ValidatedStringMixin):
     ValidationRegex = r'^[^\d\W]\w*$'
 
+    @classmethod
+    def is_valid(cls, value: Any) -> bool:
+        if not isinstance(value, str):
+            return False
+
+        try:
+            cls.validate(value)
+        except ValidationError:
+            return False
+
+        return True
+
 
 register_pattern(Name, r'^[^\d\W]\w*$')
 
diff --git a/core/dbt/task/init.py b/core/dbt/task/init.py
--- a/core/dbt/task/init.py
+++ b/core/dbt/task/init.py
@@ -14,6 +14,8 @@
 from dbt.version import _get_adapter_plugin_names
 from dbt.adapters.factory import load_plugin, get_include_paths
 
+from dbt.contracts.project import Name as ProjectName
+
 from dbt.events.functions import fire_event
 from dbt.events.types import (
     StarterProjectPath, ConfigFolderDirectory, NoSampleProfileFound, ProfileWrittenWithSample,
@@ -269,6 +271,16 @@ def ask_for_adapter_choice(self) -> str:
         numeric_choice = click.prompt(prompt_msg, type=click.INT)
         return available_adapters[numeric_choice - 1]
 
+    def get_valid_project_name(self) -> str:
+        """Returns a valid project name, either from CLI arg or user prompt."""
+        name = self.args.project_name
+        while not ProjectName.is_valid(name):
+            if name:
+                click.echo(name + " is not a valid project name.")
+            name = click.prompt("Enter a name for your project (letters, digits, underscore)")
+
+        return name
+
     def run(self):
         """Entry point for the init task."""
         profiles_dir = flags.PROFILES_DIR
@@ -306,11 +318,7 @@ def run(self):
 
         # When dbt init is run outside of an existing project,
         # create a new project and set up the user's profile.
-        project_name = self.args.project_name
-        if project_name is None:
-            # If project name is not provided,
-            # ask the user which project name they'd like to use.
-            project_name = click.prompt("What is the desired project name?")
+        project_name = self.get_valid_project_name()
         project_path = Path(project_name)
         if project_path.exists():
             fire_event(ProjectNameAlreadyExists(name=project_name))


Test patch:
diff --git a/test/integration/040_init_tests/test_init.py b/test/integration/040_init_tests/test_init.py
--- a/test/integration/040_init_tests/test_init.py
+++ b/test/integration/040_init_tests/test_init.py
@@ -327,7 +327,7 @@ def test_postgres_init_task_outside_of_project(self, mock_prompt, mock_confirm):
         ]
         self.run_dbt(['init'])
         manager.assert_has_calls([
-            call.prompt('What is the desired project name?'),
+            call.prompt("Enter a name for your project (letters, digits, underscore)"),
             call.prompt("Which database would you like to use?\n[1] postgres\n\n(Don't see the one you want? https://docs.getdbt.com/docs/available-adapters)\n\nEnter a number", type=click.INT),
             call.prompt('host (hostname for the instance)', default=None, hide_input=False, type=None),
             call.prompt('port', default=5432, hide_input=False, type=click.INT),
@@ -532,6 +532,48 @@ def test_postgres_init_with_provided_project_name(self, mock_prompt, mock_confir
       +materialized: view
 """
 
+    @use_profile('postgres')
+    @mock.patch('click.confirm')
+    @mock.patch('click.prompt')
+    def test_postgres_init_invalid_project_name_cli(self, mock_prompt, mock_confirm):
+        manager = Mock()
+        manager.attach_mock(mock_prompt, 'prompt')
+        manager.attach_mock(mock_confirm, 'confirm')
+
+        os.remove('dbt_project.yml')
+        invalid_name = 'name-with-hyphen'
+        valid_name = self.get_project_name()
+        manager.prompt.side_effect = [
+            valid_name
+        ]
+
+        self.run_dbt(['init', invalid_name, '-s'])
+        manager.assert_has_calls([
+            call.prompt("Enter a name for your project (letters, digits, underscore)"),
+        ])
+
+    @use_profile('postgres')
+    @mock.patch('click.confirm')
+    @mock.patch('click.prompt')
+    def test_postgres_init_invalid_project_name_prompt(self, mock_prompt, mock_confirm):
+        manager = Mock()
+        manager.attach_mock(mock_prompt, 'prompt')
+        manager.attach_mock(mock_confirm, 'confirm')
+
+        os.remove('dbt_project.yml')
+
+        invalid_name = 'name-with-hyphen'
+        valid_name = self.get_project_name()
+        manager.prompt.side_effect = [
+            invalid_name, valid_name
+        ]
+
+        self.run_dbt(['init', '-s'])
+        manager.assert_has_calls([
+            call.prompt("Enter a name for your project (letters, digits, underscore)"),
+            call.prompt("Enter a name for your project (letters, digits, underscore)"),
+        ])
+
     @use_profile('postgres')
     @mock.patch('click.confirm')
     @mock.patch('click.prompt')
@@ -546,20 +588,12 @@ def test_postgres_init_skip_profile_setup(self, mock_prompt, mock_confirm):
         project_name = self.get_project_name()
         manager.prompt.side_effect = [
             project_name,
-            1,
-            'localhost',
-            5432,
-            'test_username',
-            'test_password',
-            'test_db',
-            'test_schema',
-            4,
         ]
 
         # provide project name through the ini command
         self.run_dbt(['init', '-s'])
         manager.assert_has_calls([
-          call.prompt('What is the desired project name?')
+            call.prompt("Enter a name for your project (letters, digits, underscore)")
         ])
 
         with open(os.path.join(self.test_root_dir, project_name, 'dbt_project.yml'), 'r') as f: