Repository: meltano/meltano

Issue:
feature: `--cwd` CLI option
### Feature scope

CLI (options, error messages, logging, etc.)

### Description

As seen in some other CLIs, it'd be nice to have a `--cwd` option which takes a path to a directory as an argument, and runs the program as-if it were invoked from that directory. This could be implemented by calling [`os.chdir`](https://docs.python.org/3/library/os.html#os.chdir) right after the CLI options are parsed if the `--cwd` option is provided.


Discussion:
Meltano does have `MELTANO_PROJECT_ROOT` which has a similar function. A cli option would be nice.
@edgarrmondragon I wasn't aware `MELTANO_PROJECT_ROOT` could be set before execution to make Meltano use that directory. I just thought it was set by Meltano to be used by plugins and for env var expansion in settings. Thanks for the tip, and yeah, even with that I'd still appreciate a CLI option.

Resolving patch:
diff --git a/src/meltano/cli/cli.py b/src/meltano/cli/cli.py
--- a/src/meltano/cli/cli.py
+++ b/src/meltano/cli/cli.py
@@ -3,7 +3,9 @@
 from __future__ import annotations
 
 import logging
+import os
 import sys
+from pathlib import Path
 from typing import NoReturn
 
 import click
@@ -56,6 +58,11 @@ def main(self, *args, **kwargs) -> NoReturn:
 @click.option(
     "--no-environment", is_flag=True, default=False, help="Don't use any environment."
 )
+@click.option(
+    "--cwd",
+    type=click.Path(exists=True, file_okay=False, resolve_path=True, path_type=Path),
+    help="Run Meltano as if it had been started in the specified directory.",
+)
 @click.version_option(version=meltano.__version__, prog_name="meltano")
 @click.pass_context
 def cli(  # noqa: WPS231
@@ -65,6 +72,7 @@ def cli(  # noqa: WPS231
     verbose: int,
     environment: str,
     no_environment: bool,
+    cwd: Path | None,
 ):  # noqa: WPS231
     """
     ELT for the DataOps era.
@@ -85,6 +93,12 @@ def cli(  # noqa: WPS231
     if no_color:
         ctx.color = False
 
+    if cwd:
+        try:
+            os.chdir(cwd)
+        except OSError as ex:
+            raise Exception(f"Unable to run Meltano from {cwd!r}") from ex
+
     try:  # noqa: WPS229
         project = Project.find()
         setup_logging(project)


Test patch:
diff --git a/tests/meltano/cli/test_cli.py b/tests/meltano/cli/test_cli.py
--- a/tests/meltano/cli/test_cli.py
+++ b/tests/meltano/cli/test_cli.py
@@ -3,6 +3,7 @@
 import platform
 import re
 import shutil
+from pathlib import Path
 from time import perf_counter_ns
 
 import click
@@ -11,6 +12,8 @@
 from structlog.stdlib import get_logger
 
 import meltano
+from asserts import assert_cli_runner
+from fixtures.utils import cd
 from meltano.cli import cli, handle_meltano_error
 from meltano.cli.utils import CliError
 from meltano.core.error import EmptyMeltanoFileException, MeltanoError
@@ -174,6 +177,37 @@ def test_handle_meltano_error(self):
         with pytest.raises(CliError, match="This failed. Try again."):
             handle_meltano_error(exception)
 
+    def test_cwd_option(self, cli_runner, project, tmp_path: Path, pushd):
+        with cd(project.root_dir()):
+            assert_cli_runner(cli_runner.invoke(cli, ("dragon",)))
+            assert Path().resolve() == project.root_dir()
+
+        with cd(project.root_dir()):
+            assert_cli_runner(
+                cli_runner.invoke(cli, ("--cwd", str(tmp_path), "dragon"))
+            )
+            assert Path().resolve() == tmp_path
+
+        with cd(project.root_dir()):
+            filepath = tmp_path / "file.txt"
+            filepath.touch()
+            with pytest.raises(click.BadParameter, match="is a file"):
+                raise cli_runner.invoke(
+                    cli, ("--cwd", str(filepath), "dragon")
+                ).exception.__context__
+
+        with cd(project.root_dir()):
+            dirpath = tmp_path / "subdir"
+            with pytest.raises(click.BadParameter, match="does not exist"):
+                raise cli_runner.invoke(
+                    cli, ("--cwd", str(dirpath), "dragon")
+                ).exception.__context__
+
+        with cd(project.root_dir()):
+            dirpath.mkdir()
+            assert_cli_runner(cli_runner.invoke(cli, ("--cwd", str(dirpath), "dragon")))
+            assert Path().resolve() == dirpath
+
 
 def _get_dummy_logging_config(colors=True):
     return {