Repository: meltano/meltano

Issue:
feature: Allow `meltano init` to create a project in an empty directory
### Feature scope

CLI (options, error messages, logging, etc.)

### Description

### Current behaviour

Supply a project name interactively:
```
meltano init
```

Supply a project name to the command:
```
meltano init <project-name>
```

### Optimisation
Both of the above approaches require the user to `cd` into the resulting directory that is created for you:

```sh
meltano init project
cd project
```

It would be nice to be able to create a project in-place:

```
meltano init .
```

Here, the project contents should be created as normal in the current working directory. The command output should be adjusted to account for in-place operation (e.g. remove `cd <project-name>` line under the `Next steps` section).

Creating a project in any other empty (existing) directory:

```
meltano init /path/to/empty/dir
```

Running within non-empty directories should check for potential file conflicts and preemptively fail the operation.

### Example use-case
If a user wanted to version control a new project via a remote repository (GitHub or similar), creating it in-place would avoid having to copy/move the contents of the project directory into the cloned empty repository directory.

Current:
```sh
git clone git@github.com:<user>/my-meltano-project.git

meltano init temp-project
cp -a temp-project/. ../my-meltano project

cd my-meltano-project

git add .
git push

# optionally, clean-up
rm -rf temp-project
```

Proposed;
```sh
git clone git@github.com:<user>/my-meltano-project.git
cd my-meltano-project

meltano init .

git add .
git push

# no cleanup necessary
```

### Comparison to `git init`
Initialise a Git repository in a new directory:
![image](https://user-images.githubusercontent.com/60552974/192675002-632e7bc7-d8e1-4fb2-a917-501282240a16.png)

This is the same behaviour as `meltano init`.

Initialise a Git repository in an existing directory:
![image](https://user-images.githubusercontent.com/60552974/192675129-df127aff-152b-4f9b-bbab-34241d9541ac.png)
![image](https://user-images.githubusercontent.com/60552974/192675157-6189ec08-fd11-4a79-9709-8fe76bf6764f.png)

No argument and `.` result in the same behaviour, whereas currently `meltano init` with no arguments initiates interactive setup.

---

Let me know what you think - happy to open a PR as always! :smile:


Discussion:

Resolving patch:
diff --git a/src/meltano/cli/initialize.py b/src/meltano/cli/initialize.py
--- a/src/meltano/cli/initialize.py
+++ b/src/meltano/cli/initialize.py
@@ -2,6 +2,7 @@
 from __future__ import annotations
 
 import logging
+from pathlib import Path
 
 import click
 
@@ -18,25 +19,28 @@
 ALL = "all"
 
 logger = logging.getLogger(__name__)
+path_type = click.Path(file_okay=False, path_type=Path)
 
 
 @cli.command(cls=InstrumentedCmd, short_help="Create a new Meltano project.")
 @click.pass_context
-@click.argument("project_name", required=False)
+@click.argument("project_directory", required=False, type=path_type)
 @click.option(
     "--no_usage_stats", help="Do not send anonymous usage stats.", is_flag=True
 )
 @database_uri_option
-def init(ctx, project_name, no_usage_stats):
+def init(ctx, project_directory: Path, no_usage_stats):
     """
     Create a new Meltano project.
 
     Read more at https://docs.meltano.com/reference/command-line-interface#init
 
     """
-    if not project_name:
+    if not project_directory:
         click.echo("We need a project name to get started!")
-        project_name = click.prompt("Enter a name now to create a Meltano project")
+        project_directory = click.prompt(
+            "Enter a name now to create a Meltano project", type=path_type
+        )
 
     if ctx.obj["project"]:
         root = ctx.obj["project"].root
@@ -46,10 +50,10 @@ def init(ctx, project_name, no_usage_stats):
     if no_usage_stats:
         ProjectSettingsService.config_override["send_anonymous_usage_stats"] = False
 
-    init_service = ProjectInitService(project_name)
+    init_service = ProjectInitService(project_directory)
 
     project = init_service.init()
-    init_service.echo_instructions()
+    init_service.echo_instructions(project)
 
     # since the project didn't exist, tracking was not initialized in cli.py
     # but now that we've created a project we can set up telemetry and fire events.
diff --git a/src/meltano/cli/params.py b/src/meltano/cli/params.py
--- a/src/meltano/cli/params.py
+++ b/src/meltano/cli/params.py
@@ -59,7 +59,7 @@ def decorate(*args, **kwargs):
             if not project:
                 raise CliError(
                     f"`{ctx.command_path}` must be run inside a Meltano project.\n"
-                    + "Use `meltano init <project_name>` to create one."
+                    + "Use `meltano init <project_directory>` to create one."
                 )
 
             # register the system database connection
diff --git a/src/meltano/core/project_init_service.py b/src/meltano/core/project_init_service.py
--- a/src/meltano/core/project_init_service.py
+++ b/src/meltano/core/project_init_service.py
@@ -3,6 +3,7 @@
 
 import os
 import uuid
+from pathlib import Path
 
 import click
 
@@ -20,13 +21,18 @@ class ProjectInitServiceError(Exception):
 class ProjectInitService:
     """New Project Initialization Service."""
 
-    def __init__(self, project_name):
+    def __init__(self, project_directory: os.PathLike):
         """Create a new ProjectInitService instance.
 
         Args:
-            project_name: The name of the project to create
+            project_directory: The directory path to create the project at
         """
-        self.project_name = project_name.lower()
+        self.project_directory = Path(project_directory)
+
+        try:
+            self.project_directory = self.project_directory.relative_to(Path.cwd())
+        except ValueError:
+            pass
 
     def init(self, activate: bool = True, add_discovery: bool = False) -> Project:
         """Initialise Meltano Project.
@@ -42,28 +48,26 @@ def init(self, activate: bool = True, add_discovery: bool = False) -> Project:
             ProjectInitServiceError: Directory already exists
         """
         try:
-            os.mkdir(self.project_name)
+            self.project_directory.mkdir()
         except FileExistsError as ex:
-            raise ProjectInitServiceError(
-                f"Directory {self.project_name!r} already exists."
-            ) from ex
+            if any(self.project_directory.iterdir()):
+                raise ProjectInitServiceError(
+                    f"Directory '{self.project_directory}' not empty."
+                ) from ex
         except PermissionError as ex:
             raise ProjectInitServiceError(
-                f"Permission denied to create {self.project_name!r}."
+                f"Permission denied to create '{self.project_directory}'."
             ) from ex
         except Exception as ex:
             raise ProjectInitServiceError(
-                f"Could not create directory {self.project_name!r}. {ex}"
+                f"Could not create directory '{self.project_directory}'. {ex}"
             ) from ex
 
-        click.secho("Created", fg="blue", nl=False)
-        click.echo(f" {self.project_name}")
-
-        self.project = Project(self.project_name)
+        project = Project(self.project_directory)
 
-        self.create_files(add_discovery=add_discovery)
+        self.create_files(project, add_discovery=add_discovery)
 
-        self.settings_service = ProjectSettingsService(self.project)
+        self.settings_service = ProjectSettingsService(project)
         self.settings_service.set(
             "project_id",
             str(uuid.uuid4()),
@@ -71,32 +75,39 @@ def init(self, activate: bool = True, add_discovery: bool = False) -> Project:
         )
         self.set_send_anonymous_usage_stats()
         if activate:
-            Project.activate(self.project)
+            Project.activate(project)
+
+        self.create_system_database(project)
 
-        self.create_system_database()
+        return project
 
-        return self.project
+    def create_dot_meltano_dir(self, project: Project):
+        """Create .meltano directory.
 
-    def create_dot_meltano_dir(self):
-        """Create .meltano directory."""
+        Args:
+            project: Meltano project context
+        """
         # explicitly create the .meltano directory if it doesn't exist
-        os.makedirs(self.project.meltano_dir(), exist_ok=True)
+        os.makedirs(project.meltano_dir(), exist_ok=True)
         click.secho("   |--", fg="blue", nl=False)
-        click.echo(f" {self.project.meltano_dir().name}")
+        click.echo(f" {project.meltano_dir().name}")
 
-    def create_files(self, add_discovery=False):
+    def create_files(self, project: Project, add_discovery=False):
         """Create project files.
 
         Args:
+            project: Meltano project context
             add_discovery: Add discovery.yml file to created project
         """
         click.secho("Creating project files...", fg="blue")
-        click.echo(f"  {self.project_name}/")
 
-        self.create_dot_meltano_dir()
+        if project.root != Path.cwd():
+            click.echo(f"  {self.project_directory}/")
+
+        self.create_dot_meltano_dir(project)
 
         plugin = MeltanoFilePlugin(discovery=add_discovery)
-        for path in plugin.create_files(self.project):
+        for path in plugin.create_files(project):
             click.secho("   |--", fg="blue", nl=False)
             click.echo(f" {path}")
 
@@ -110,33 +121,38 @@ def set_send_anonymous_usage_stats(self):
                 store=SettingValueStore.MELTANO_YML,
             )
 
-    def create_system_database(self):
+    def create_system_database(self, project: Project):
         """Create Meltano System DB.
 
+        Args:
+            project: Meltano project context
+
         Raises:
             ProjectInitServiceError: Database initialization failed
         """
         click.secho("Creating system database...", fg="blue", nl=False)
 
         # register the system database connection
-        engine, _ = project_engine(self.project, default=True)
+        engine, _ = project_engine(project, default=True)
 
         from meltano.core.migration_service import MigrationError, MigrationService
 
         try:
             migration_service = MigrationService(engine)
             migration_service.upgrade(silent=True)
-            migration_service.seed(self.project)
+            migration_service.seed(project)
             click.secho("  Done!", fg="blue")
         except MigrationError as err:
             raise ProjectInitServiceError(str(err)) from err
 
-    def echo_instructions(self):
-        """Echo Next Steps to Click CLI."""
+    def echo_instructions(self, project: Project):
+        """Echo Next Steps to Click CLI.
+
+        Args:
+            project: Meltano project context
+        """
         click.secho(GREETING, nl=False)
-        click.secho("\nProject ", nl=False)
-        click.secho(self.project_name, fg="magenta", nl=False)
-        click.echo(" has been created!\n")
+        click.echo("Your project has been created!\n")
 
         click.echo("Meltano Environments initialized with ", nl=False)
         click.secho("dev", fg="bright_green", nl=False)
@@ -152,23 +168,15 @@ def echo_instructions(self):
         )
 
         click.echo("\nNext steps:")
-        click.secho("  cd ", nl=False)
-        click.secho(self.project_name, fg="magenta")
+
+        if project.root != Path.cwd():
+            click.secho("  cd ", nl=False)
+            click.secho(self.project_directory, fg="magenta")
+
         click.echo("  Visit ", nl=False)
         click.secho(
-            "https://docs.meltano.com/getting-started#create-your-meltano-project",
+            "https://docs.meltano.com/getting-started/part1",
             fg="cyan",
             nl=False,
         )
         click.echo(" to learn where to go from here")
-
-    def join_with_project_base(self, filename):
-        """Join Path to Project base.
-
-        Args:
-            filename: File name to join with project base
-
-        Returns:
-            Joined base path and passed filename
-        """
-        return os.path.join(".", self.project_name, filename)


Test patch:
diff --git a/tests/meltano/cli/test_initialize.py b/tests/meltano/cli/test_initialize.py
--- a/tests/meltano/cli/test_initialize.py
+++ b/tests/meltano/cli/test_initialize.py
@@ -7,7 +7,7 @@
 
 
 class TestCliInit:
-    def test_init(self, cli_runner, tmp_path_factory, pushd):
+    def test_init(self, cli_runner, tmp_path_factory, pushd):  # noqa: WPS210
         new_project_root = tmp_path_factory.mktemp("new_meltano_root")
         pushd(new_project_root)
 
@@ -53,3 +53,28 @@ def test_init(self, cli_runner, tmp_path_factory, pushd):
         meltano_yml = project.root_dir("meltano.yml").read_text()
         assert "send_anonymous_usage_stats: false" in meltano_yml
         assert "project_id:" in meltano_yml
+
+    def test_init_existing_empty_directory(self, cli_runner, tmp_path_factory, pushd):
+        new_project_root = tmp_path_factory.mktemp("new_meltano_root")
+        pushd(new_project_root)
+
+        # create project in empty current working directory
+        result = cli_runner.invoke(cli, ["init", ".", "--no_usage_stats"])
+        assert "Creating project files...\n  ./" not in result.output
+        assert "cd ." not in result.output
+
+        Project.deactivate()
+
+        new_project_root = tmp_path_factory.mktemp("new_meltano_root")
+        pushd(new_project_root)
+
+        # create project in another empty directory
+        project_dir = new_project_root.joinpath("test_project")
+        project_dir.mkdir()
+        assert project_dir.exists()
+
+        result = cli_runner.invoke(cli, ["init", "test_project", "--no_usage_stats"])
+        assert "Creating project files...\n  test_project/" in result.output
+        assert "cd test_project" in result.output
+
+        Project.deactivate()
diff --git a/tests/meltano/core/test_project_init_service.py b/tests/meltano/core/test_project_init_service.py
--- a/tests/meltano/core/test_project_init_service.py
+++ b/tests/meltano/core/test_project_init_service.py
@@ -1,8 +1,9 @@
 from __future__ import annotations
 
 import platform
+import shutil
 import stat
-from typing import TYPE_CHECKING
+from pathlib import Path
 
 import pytest
 
@@ -11,21 +12,53 @@
     ProjectInitServiceError,
 )
 
-if TYPE_CHECKING:
-    from pathlib import Path
 
+@pytest.mark.parametrize(
+    "create_project_dir",
+    (
+        False,
+        True,
+    ),
+    ids=(
+        "new directory",
+        "empty directory",
+    ),
+)
+def test_project_init_success(create_project_dir: bool, tmp_path: Path, pushd):
+    projects_dir = tmp_path.joinpath("success")
+    projects_dir.mkdir()
+    pushd(projects_dir)
+
+    project_dir = projects_dir.joinpath("test_project")
+
+    if create_project_dir:
+        project_dir.mkdir()
+
+    ProjectInitService(project_dir.relative_to(Path.cwd())).init(
+        activate=False, add_discovery=False
+    )
+
+    shutil.rmtree(project_dir)
+
+    if create_project_dir:
+        project_dir.mkdir()
+
+    ProjectInitService(project_dir.absolute()).init(activate=False, add_discovery=False)
 
-def test_project_init_directory_exists(tmp_path: Path, pushd):
-    project_name = "test_project"
 
+def test_project_init_non_empty_directory(tmp_path: Path, pushd):
     projects_dir = tmp_path.joinpath("exists")
-    projects_dir.joinpath(project_name).mkdir(parents=True)
+    projects_dir.mkdir()
     pushd(projects_dir)
+
+    project_dir = projects_dir.joinpath("test_project")
+    project_dir.joinpath("test").mkdir(parents=True)
+
     with pytest.raises(
         ProjectInitServiceError,
-        match=f"Directory {project_name!r} already exists",
+        match="Directory 'test_project' not empty",
     ):
-        ProjectInitService(project_name).init(activate=False, add_discovery=False)
+        ProjectInitService(project_dir).init(activate=False, add_discovery=False)
 
 
 def test_project_init_no_write_permission(tmp_path: Path, pushd):
@@ -33,17 +66,19 @@ def test_project_init_no_write_permission(tmp_path: Path, pushd):
         pytest.xfail(
             "Windows can still create new directories inside a read-only directory."
         )
-    project_name = "test_project"
 
     protected_dir = tmp_path.joinpath("protected")
     protected_dir.mkdir()
     protected_dir.chmod(stat.S_IREAD | stat.S_IEXEC)  # read and execute, but not write
     pushd(protected_dir)
+
+    project_dir = protected_dir.joinpath("test_project")
+
     with pytest.raises(
         ProjectInitServiceError,
-        match=f"Permission denied to create {project_name!r}",
+        match="Permission denied to create 'test_project'",
     ):
-        ProjectInitService(project_name).init(activate=False, add_discovery=False)
+        ProjectInitService(project_dir).init(activate=False, add_discovery=False)
 
 
 def test_project_init_missing_parent_directory(tmp_path: Path, pushd):
@@ -51,14 +86,18 @@ def test_project_init_missing_parent_directory(tmp_path: Path, pushd):
         pytest.xfail(
             "Windows can't remove a directory that is in use. See https://docs.python.org/3/library/os.html#os.remove"
         )
-    project_name = "test_project"
 
     missing_dir = tmp_path.joinpath("missing")
     missing_dir.mkdir()
     pushd(missing_dir)
-    missing_dir.rmdir()  # remove the parent directory
+
+    project_dir = missing_dir.joinpath("test_project")
+
     with pytest.raises(
         ProjectInitServiceError,
-        match=f"Could not create directory {project_name!r}.",
+        match="Could not create directory 'test_project'.",
     ):
-        ProjectInitService(project_name).init(activate=False, add_discovery=False)
+        project_init_service = ProjectInitService(project_dir)
+        missing_dir.rmdir()  # remove the parent directory
+
+        project_init_service.init(activate=False, add_discovery=False)