Repository: Cog-Creators/Red-DiscordBot

Issue:
Allow redbot-setup to be run in headless mode
See https://cogboard.red/t/allow-headless-setup-command/250

Necessary for Docker deployment of v3, otherwise an [Expect script](https://forums.docker.com/t/dockerfile-how-to-answer-install-question-from-application/5240) needs to be written to answer the GUI questions for redbot-setup.


Discussion:
Chat log discussing redbot-setup headless mode with Twentysix: https://pastebin.com/CAC99tpn
It’s been 3 days since me trying to dockerize redbot. 
Been trying to attach config.json and settings.json, but still get errors.
Run it as “redbot —no-instance” etc

How do I skip “First run configuration master” ?
If you'd like to PR a change required for this, by all means, do so. In the meantime, each open PR for an official docker image (competing methods to handle it) is copying a preconfigured config.json into the image as this use case really isn't applicable to the majority of our users outside of docker currently. I'd recommend you do the same if you can't wait for a solution to be reviewed and up to our standards as an official image.

Refer to #2273 (current frontrunner) and #2163 (a possible alternative) for more details on this.
@wMw9 I have successfully built a container for v3 without headless mode using Expect scripts to answer the interactive install menus. With this method you can create Red containers without filling out and mounting a config.json and settings.json. You can send me a message on Discord **LGA Code#0001** if you want more info.
i'm interested in doing this - I've been thinking about changing the way we store each instance's metadata and settings. I think it'd be beneficial for users and developers alike to have a standardized, documented config file in YAML format, one for each instance, which describes things like data path, storage backend details, and maybe some other stuff too. This'd also mean the `redbot` script could be run with a `--config-file <path_to_file>` argument in place of an instance name. The `redbot-setup` script would just generate one of these config files and place it in the usual config directory.

Resolving patch:
diff --git a/redbot/setup.py b/redbot/setup.py
--- a/redbot/setup.py
+++ b/redbot/setup.py
@@ -64,10 +64,13 @@ def save_config(name, data, remove=False):
         json.dump(_config, fs, indent=4)
 
 
-def get_data_dir(instance_name: str):
+def get_data_dir(*, instance_name: str, data_path: Optional[Path], interactive: bool) -> str:
+    if data_path is not None:
+        return str(data_path.resolve())
     data_path = Path(appdir.user_data_dir) / "data" / instance_name
+    if not interactive:
+        return str(data_path.resolve())
 
-    print()
     print(
         "We've attempted to figure out a sane default data location which is printed below."
         " If you don't want to change this default please press [ENTER],"
@@ -108,15 +111,23 @@ def get_data_dir(instance_name: str):
     return str(data_path.resolve())
 
 
-def get_storage_type():
-    storage_dict = {1: "JSON", 2: "PostgreSQL"}
+def get_storage_type(backend: Optional[str], *, interactive: bool):
+    if backend:
+        return get_target_backend(backend)
+    if not interactive:
+        return BackendType.JSON
+    storage_dict = {1: BackendType.JSON, 2: BackendType.POSTGRES}
     storage = None
     while storage is None:
         print()
-        print("Please choose your storage backend (if you're unsure, just choose 1).")
+        print("Please choose your storage backend.")
         print("1. JSON (file storage, requires no database).")
         print("2. PostgreSQL (Requires a database server)")
+        print("If you're unsure, press [ENTER] to use the recommended default - JSON.")
+
         storage = input("> ")
+        if not storage:
+            return BackendType.JSON
         try:
             storage = int(storage)
         except ValueError:
@@ -124,11 +135,20 @@ def get_storage_type():
         else:
             if storage not in storage_dict:
                 storage = None
-    return storage
+    return storage_dict[storage]
 
 
-def get_name() -> str:
-    name = ""
+def get_name(name: str) -> str:
+    INSTANCE_NAME_RE = re.compile(r"[A-Za-z0-9_\.\-]*")
+    if name:
+        if INSTANCE_NAME_RE.fullmatch(name) is None:
+            print(
+                "ERROR: Instance names can only include characters A-z, numbers, "
+                "underscores (_) and periods (.)."
+            )
+            sys.exit(1)
+        return name
+
     while len(name) == 0:
         print(
             "Please enter a name for your instance,"
@@ -137,7 +157,7 @@ def get_name() -> str:
             " A-z, numbers, underscores (_) and periods (.)."
         )
         name = input("> ")
-        if re.fullmatch(r"[A-Za-z0-9_\.\-]*", name) is None:
+        if INSTANCE_NAME_RE.fullmatch(name) is None:
             print(
                 "ERROR: Instance names can only include characters A-z, numbers, "
                 "underscores (_) and periods (.)."
@@ -153,54 +173,84 @@ def get_name() -> str:
     return name
 
 
-def basic_setup():
+def basic_setup(
+    *,
+    name: str,
+    data_path: Optional[Path],
+    backend: Optional[str],
+    interactive: bool,
+    overwrite_existing_instance: bool,
+):
     """
     Creates the data storage folder.
     :return:
     """
+    if not interactive and not name:
+        print(
+            "Providing instance name through --instance-name is required"
+            " when using non-interactive mode."
+        )
+        sys.exit(1)
 
-    print(
-        "Hello! Before we begin, we need to gather some initial information for the new instance."
-    )
-    name = get_name()
+    if interactive:
+        print(
+            "Hello! Before we begin, we need to gather some initial information"
+            " for the new instance."
+        )
+    name = get_name(name)
 
-    default_data_dir = get_data_dir(name)
+    default_data_dir = get_data_dir(
+        instance_name=name, data_path=data_path, interactive=interactive
+    )
 
     default_dirs = deepcopy(data_manager.basic_config_default)
     default_dirs["DATA_PATH"] = default_data_dir
 
-    storage = get_storage_type()
+    storage_type = get_storage_type(backend, interactive=interactive)
 
-    storage_dict = {1: BackendType.JSON, 2: BackendType.POSTGRES}
-    storage_type: BackendType = storage_dict.get(storage, BackendType.JSON)
     default_dirs["STORAGE_TYPE"] = storage_type.value
     driver_cls = drivers.get_driver_class(storage_type)
     default_dirs["STORAGE_DETAILS"] = driver_cls.get_config_details()
 
     if name in instance_data:
-        print(
-            "WARNING: An instance already exists with this name. "
-            "Continuing will overwrite the existing instance config."
-        )
-        if not click.confirm("Are you absolutely certain you want to continue?", default=False):
-            print("Not continuing")
-            sys.exit(0)
+        if overwrite_existing_instance:
+            pass
+        elif interactive:
+            print(
+                "WARNING: An instance already exists with this name. "
+                "Continuing will overwrite the existing instance config."
+            )
+            if not click.confirm(
+                "Are you absolutely certain you want to continue?", default=False
+            ):
+                print("Not continuing")
+                sys.exit(0)
+        else:
+            print(
+                "An instance with this name already exists.\n"
+                "If you want to remove the existing instance and replace it with this one,"
+                " run this command with --overwrite-existing-instance flag."
+            )
+            sys.exit(1)
     save_config(name, default_dirs)
 
-    print()
-    print(
-        "Your basic configuration has been saved. Please run `redbot <name>` to"
-        " continue your setup process and to run the bot.\n\n"
-        "First time? Read the quickstart guide:\n"
-        "https://docs.discord.red/en/stable/getting_started.html"
-    )
+    if interactive:
+        print()
+        print(
+            f"Your basic configuration has been saved. Please run `redbot {name}` to"
+            " continue your setup process and to run the bot.\n\n"
+            "First time? Read the quickstart guide:\n"
+            "https://docs.discord.red/en/stable/getting_started.html"
+        )
+    else:
+        print("Your basic configuration has been saved.")
 
 
-def get_current_backend(instance) -> BackendType:
+def get_current_backend(instance: str) -> BackendType:
     return BackendType(instance_data[instance]["STORAGE_TYPE"])
 
 
-def get_target_backend(backend) -> BackendType:
+def get_target_backend(backend: str) -> BackendType:
     if backend == "json":
         return BackendType.JSON
     elif backend == "postgres":
@@ -243,13 +293,13 @@ async def create_backup(instance: str, destination_folder: Path = Path.home()) -
 
 
 async def remove_instance(
-    instance,
+    instance: str,
     interactive: bool = False,
     delete_data: Optional[bool] = None,
     _create_backup: Optional[bool] = None,
     drop_db: Optional[bool] = None,
     remove_datapath: Optional[bool] = None,
-):
+) -> None:
     data_manager.load_basic_configuration(instance)
     backend = get_current_backend(instance)
 
@@ -286,10 +336,10 @@ async def remove_instance(
         safe_delete(data_path)
 
     save_config(instance, {}, remove=True)
-    print("The instance {} has been removed\n".format(instance))
+    print("The instance {} has been removed.".format(instance))
 
 
-async def remove_instance_interaction():
+async def remove_instance_interaction() -> None:
     if not instance_list:
         print("No instances have been set up!")
         return
@@ -312,8 +362,54 @@ async def remove_instance_interaction():
 
 @click.group(invoke_without_command=True)
 @click.option("--debug", type=bool)
+@click.option(
+    "--no-prompt",
+    "interactive",
+    type=bool,
+    is_flag=True,
+    default=True,
+    help=(
+        "Don't ask for user input during the process (non-interactive mode)."
+        " This makes `--instance-name` required."
+    ),
+)
+@click.option(
+    "--instance-name",
+    type=str,
+    default="",
+    help="Name of the new instance. Required if --no-prompt is passed.",
+)
+@click.option(
+    "--data-path",
+    type=click.Path(exists=False, dir_okay=True, file_okay=False, writable=True, path_type=Path),
+    default=None,
+    help=(
+        "Data path of the new instance. If this option and --no-prompt are omitted,"
+        " you will be asked for this."
+    ),
+)
+@click.option(
+    "--backend",
+    type=click.Choice(["json", "postgres"]),
+    default=None,
+    help=(
+        "Choose a backend type for the new instance."
+        " If this option is omitted, you will be asked for this."
+        " Defaults to JSON in non-interactive mode.\n"
+        "Note: Choosing PostgreSQL will prevent the setup from being completely non-interactive."
+    ),
+)
+@click.option("--overwrite-existing-instance", type=bool, is_flag=True)
 @click.pass_context
-def cli(ctx, debug):
+def cli(
+    ctx: click.Context,
+    debug: bool,
+    interactive: bool,
+    instance_name: str,
+    data_path: Optional[Path],
+    backend: Optional[str],
+    overwrite_existing_instance: bool,
+) -> None:
     """Create a new instance."""
     level = logging.DEBUG if debug else logging.INFO
     base_logger = logging.getLogger("red")
@@ -326,7 +422,13 @@ def cli(ctx, debug):
     base_logger.addHandler(stdout_handler)
 
     if ctx.invoked_subcommand is None:
-        basic_setup()
+        basic_setup(
+            name=instance_name,
+            data_path=data_path,
+            backend=backend,
+            overwrite_existing_instance=overwrite_existing_instance,
+            interactive=interactive,
+        )
 
 
 @cli.command()
@@ -385,7 +487,7 @@ def delete(
     _create_backup: Optional[bool],
     drop_db: Optional[bool],
     remove_datapath: Optional[bool],
-):
+) -> None:
     """Removes an instance."""
     asyncio.run(
         remove_instance(
@@ -397,7 +499,7 @@ def delete(
 @cli.command()
 @click.argument("instance", type=click.Choice(instance_list), metavar="<INSTANCE_NAME>")
 @click.argument("backend", type=click.Choice(["json", "postgres"]))
-def convert(instance, backend):
+def convert(instance: str, backend: str) -> None:
     """Convert data backend of an instance."""
     current_backend = get_current_backend(instance)
     target = get_target_backend(backend)
@@ -427,13 +529,13 @@ def convert(instance, backend):
 @click.argument(
     "destination_folder",
     type=click.Path(
-        exists=False, dir_okay=True, file_okay=False, resolve_path=True, writable=True
+        dir_okay=True, file_okay=False, resolve_path=True, writable=True, path_type=Path
     ),
     default=Path.home(),
 )
-def backup(instance: str, destination_folder: Union[str, Path]) -> None:
+def backup(instance: str, destination_folder: Path) -> None:
     """Backup instance's data."""
-    asyncio.run(create_backup(instance, Path(destination_folder)))
+    asyncio.run(create_backup(instance, destination_folder))
 
 
 def run_cli():


Test patch: