From 5c76a1257b4a058bf919af3e31cc9461a39c2f33 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Mon, 11 Nov 2024 01:12:29 +0800
Subject: HALF WORK: 2024.1.20 - 2

---
 tools/cru-py/cru/service/_app.py      |   4 +-
 tools/cru-py/cru/service/_config.py   |  87 +++++++------
 tools/cru-py/cru/service/_data.py     |   9 --
 tools/cru-py/cru/service/_docker.py   |  19 ---
 tools/cru-py/cru/service/_external.py |  69 +++++++++++
 tools/cru-py/cru/service/_manager.py  |   4 -
 tools/cru-py/cru/service/_nginx.py    | 227 +++++++++++++++++++++++++++++++---
 tools/cru-py/cru/service/_template.py |  14 ++-
 tools/cru-py/cru/service/nginx.py     |  17 ---
 tools/cru-py/cru/template.py          |  12 +-
 10 files changed, 349 insertions(+), 113 deletions(-)
 delete mode 100644 tools/cru-py/cru/service/_data.py
 delete mode 100644 tools/cru-py/cru/service/_docker.py
 create mode 100644 tools/cru-py/cru/service/_external.py
 delete mode 100644 tools/cru-py/cru/service/_manager.py
 delete mode 100644 tools/cru-py/cru/service/nginx.py

(limited to 'tools/cru-py/cru')

diff --git a/tools/cru-py/cru/service/_app.py b/tools/cru-py/cru/service/_app.py
index e72baec..6030dad 100644
--- a/tools/cru-py/cru/service/_app.py
+++ b/tools/cru-py/cru/service/_app.py
@@ -5,9 +5,9 @@ from ._base import (
     PathCommandProvider,
 )
 from ._config import ConfigManager
-from ._data import DataManager
 from ._template import TemplateManager
 from ._nginx import NginxManager
+from ._external import CliToolCommandProvider
 
 APP_ID = "crupest"
 
@@ -17,10 +17,10 @@ class App(AppBase):
         super().__init__(APP_ID, f"{APP_ID}-service")
         self.add_feature(PathCommandProvider())
         self.add_feature(AppInitializer())
-        self.add_feature(DataManager())
         self.add_feature(ConfigManager())
         self.add_feature(TemplateManager())
         self.add_feature(NginxManager())
+        self.add_feature(CliToolCommandProvider())
         self.add_feature(CommandDispatcher())
 
     def run_command(self):
diff --git a/tools/cru-py/cru/service/_config.py b/tools/cru-py/cru/service/_config.py
index 52fed34..b51e21c 100644
--- a/tools/cru-py/cru/service/_config.py
+++ b/tools/cru-py/cru/service/_config.py
@@ -141,45 +141,46 @@ class ConfigManager(AppCommandFeatureProvider):
         super().__init__("config-manager")
         configuration = Configuration()
         self._configuration = configuration
+        self._loaded: bool = False
         self._init_app_defined_items()
 
     def _init_app_defined_items(self) -> None:
         prefix = self.config_name_prefix
 
-        def _add_text(name: str, description: str) -> None:
-            self.configuration.add(
-                ConfigItem(f"{prefix}_{name}", description, TEXT_VALUE_TYPE)
-            )
-
-        def _add_uuid(name: str, description: str) -> None:
-            self.configuration.add(
-                ConfigItem(
-                    f"{prefix}_{name}",
-                    description,
-                    TEXT_VALUE_TYPE,
-                    default=UuidValueGenerator(),
-                )
+        def _add_text(name: str, description: str) -> ConfigItem:
+            item = ConfigItem(f"{prefix}_{name}", description, TEXT_VALUE_TYPE)
+            self.configuration.add(item)
+            return item
+
+        def _add_uuid(name: str, description: str) -> ConfigItem:
+            item = ConfigItem(
+                f"{prefix}_{name}",
+                description,
+                TEXT_VALUE_TYPE,
+                default=UuidValueGenerator(),
             )
+            self.configuration.add(item)
+            return item
 
         def _add_random_string(
             name: str, description: str, length: int = 32, secure: bool = True
-        ) -> None:
-            self.configuration.add(
-                ConfigItem(
-                    f"{prefix}_{name}",
-                    description,
-                    TEXT_VALUE_TYPE,
-                    default=RandomStringValueGenerator(length, secure),
-                )
+        ) -> ConfigItem:
+            item = ConfigItem(
+                f"{prefix}_{name}",
+                description,
+                TEXT_VALUE_TYPE,
+                default=RandomStringValueGenerator(length, secure),
             )
+            self.configuration.add(item)
+            return item
 
-        def _add_int(name: str, description: str) -> None:
-            self.configuration.add(
-                ConfigItem(f"{prefix}_{name}", description, INTEGER_VALUE_TYPE)
-            )
+        def _add_int(name: str, description: str) -> ConfigItem:
+            item = ConfigItem(f"{prefix}_{name}", description, INTEGER_VALUE_TYPE)
+            self.configuration.add(item)
+            return item
 
-        _add_text("DOMAIN", "domain name")
-        _add_text("EMAIL", "admin email address")
+        self._domain = _add_text("DOMAIN", "domain name")
+        self._email = _add_text("EMAIL", "admin email address")
         _add_text(
             "AUTO_BACKUP_COS_SECRET_ID",
             "access key id for Tencent COS, used for auto backup",
@@ -247,16 +248,18 @@ class ConfigManager(AppCommandFeatureProvider):
     def get_item_value_str(self, name: str, ensure_set: bool = True) -> str | None: ...
 
     def get_item_value_str(self, name: str, ensure_set: bool = True) -> str | None:
-        self.reload_config_file()
+        self.load_config_file()
         item = self.get_item(name)
-        if ensure_set and not item.is_set:
-            raise AppConfigItemNotSetError(
-                f"Config item '{name}' is not set.", self.configuration, [item]
-            )
+        if not item.is_set:
+            if ensure_set:
+                raise AppConfigItemNotSetError(
+                    f"Config item '{name}' is not set.", self.configuration, [item]
+                )
+            return None
         return item.value_str
 
     def get_str_dict(self, ensure_all_set: bool = True) -> dict[str, str]:
-        self.reload_config_file()
+        self.load_config_file()
         if ensure_all_set and not self.configuration.all_set:
             raise AppConfigItemNotSetError(
                 "Some config items are not set.",
@@ -265,8 +268,15 @@ class ConfigManager(AppCommandFeatureProvider):
             )
         return self.configuration.to_str_dict()
 
-    def get_domain_item_name(self) -> str:
-        return f"{self.config_name_prefix}_DOMAIN"
+    @property
+    def domain_item_name(self) -> str:
+        return self._domain.name
+
+    def get_domain_value_str(self) -> str:
+        return self.get_item_value_str(self._domain.name)
+
+    def get_email_value_str_optional(self) -> str | None:
+        return self.get_item_value_str(self._email.name, ensure_set=False)
 
     def _set_with_default(self) -> None:
         if not self.configuration.all_not_set:
@@ -379,7 +389,7 @@ class ConfigManager(AppCommandFeatureProvider):
         value_dict = self._check_type(entry_dict)
         return value_dict
 
-    def reload_config_file(self):
+    def _real_load_config_file(self) -> None:
         self.configuration.reset_all()
         value_dict = self._read_config_file()
         for key, value in value_dict.items():
@@ -387,6 +397,11 @@ class ConfigManager(AppCommandFeatureProvider):
                 continue
             self.configuration.set_config_item(key, value)
 
+    def load_config_file(self, force=False) -> None:
+        if force or not self._loaded:
+            self._real_load_config_file()
+            self._loaded = True
+
     def _print_app_config_info(self):
         for item in self.configuration:
             print(item.description_str)
diff --git a/tools/cru-py/cru/service/_data.py b/tools/cru-py/cru/service/_data.py
deleted file mode 100644
index 885c8e8..0000000
--- a/tools/cru-py/cru/service/_data.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from ._base import AppFeatureProvider
-
-
-class DataManager(AppFeatureProvider):
-    def __init__(self) -> None:
-        super().__init__("data-manager")
-
-    def setup(self) -> None:
-        pass
diff --git a/tools/cru-py/cru/service/_docker.py b/tools/cru-py/cru/service/_docker.py
deleted file mode 100644
index 9b801c4..0000000
--- a/tools/cru-py/cru/service/_docker.py
+++ /dev/null
@@ -1,19 +0,0 @@
-import subprocess
-
-from cru.tool import ExternalTool
-
-
-class DockerController(ExternalTool):
-    DOCKER_BIN_NAME = "docker"
-
-    def __init__(self, docker_bin: None | str = None) -> None:
-        super().__init__(docker_bin or self.DOCKER_BIN_NAME)
-
-    def list_containers(self) -> L[str]:
-        p = subprocess.run(
-            [self.docker_bin, "container", "ls", ""], capture_output=True
-        )
-        return p.stdout.decode("utf-8").splitlines()
-
-    def restart_container(self, container_name: str) -> None:
-        subprocess.run([self.docker_bin, "restart", container_name])
diff --git a/tools/cru-py/cru/service/_external.py b/tools/cru-py/cru/service/_external.py
new file mode 100644
index 0000000..418316a
--- /dev/null
+++ b/tools/cru-py/cru/service/_external.py
@@ -0,0 +1,69 @@
+from ._base import AppCommandFeatureProvider
+from ._nginx import NginxManager
+
+
+class CliToolCommandProvider(AppCommandFeatureProvider):
+    def __init__(self) -> None:
+        super().__init__("cli-tool-command-provider")
+
+    def setup(self):
+        pass
+
+    def get_command_info(self):
+        return ("gen-cli", "Get commands of running external cli tools.")
+
+    def setup_arg_parser(self, arg_parser):
+        subparsers = arg_parser.add_subparsers(
+            dest="gen_cli_command", required=True, metavar="GEN_CLI_COMMAND"
+        )
+        certbot_parser = subparsers.add_parser("certbot", help="print certbot commands")
+        certbot_parser.add_argument(
+            "-t", "--test", action="store_true", help="run certbot in test mode"
+        )
+        _install_docker_parser = subparsers.add_parser(
+            "install-docker", help="print docker commands"
+        )
+
+    def _print_install_docker_commands(self) -> None:
+        output = """
+### COMMAND: uninstall apt docker
+for pkg in docker.io docker-doc docker-compose \
+podman-docker containerd runc; \
+do sudo apt-get remove $pkg; done
+
+### COMMAND: prepare apt certs
+sudo apt-get update
+sudo apt-get install ca-certificates curl
+sudo install -m 0755 -d /etc/apt/keyrings
+
+### COMMAND: install certs
+sudo curl -fsSL https://download.docker.com/linux/debian/gpg \
+-o /etc/apt/keyrings/docker.asc
+sudo chmod a+r /etc/apt/keyrings/docker.asc
+
+### COMMAND: add docker apt source
+echo \\
+  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
+https://download.docker.com/linux/debian \\
+  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \\
+  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
+
+### COMMAND: update apt and install docker
+sudo apt-get update
+sudo apt-get install docker-ce docker-ce-cli containerd.io \
+docker-buildx-plugin docker-compose-plugin
+
+### COMMAND: setup system for docker
+sudo systemctl enable docker
+sudo systemctl start docker
+sudo groupadd -f docker
+sudo usermod -aG docker $USER
+# Remember to log out and log back in for the group changes to take effect
+""".strip()
+        print(output)
+
+    def run_command(self, args):
+        if args.gen_cli_command == "certbot":
+            self.app.get_feature(NginxManager).print_all_certbot_commands(args.test)
+        elif args.gen_cli_command == "install-docker":
+            self._print_install_docker_commands()
diff --git a/tools/cru-py/cru/service/_manager.py b/tools/cru-py/cru/service/_manager.py
deleted file mode 100644
index c1af428..0000000
--- a/tools/cru-py/cru/service/_manager.py
+++ /dev/null
@@ -1,4 +0,0 @@
-class CruServiceManager:
-    "TODO: Continue here tomorrow!"
-    def __init__(self):
-        
\ No newline at end of file
diff --git a/tools/cru-py/cru/service/_nginx.py b/tools/cru-py/cru/service/_nginx.py
index ad29d21..a9013e2 100644
--- a/tools/cru-py/cru/service/_nginx.py
+++ b/tools/cru-py/cru/service/_nginx.py
@@ -1,20 +1,41 @@
 from argparse import Namespace
+from enum import Enum, auto
 import re
+import subprocess
+from typing import TypeAlias
+
+from cru import CruInternalError
 
 from ._base import AppCommandFeatureProvider
 from ._config import ConfigManager
 from ._template import TemplateManager
 
 
+class CertbotAction(Enum):
+    CREATE = auto()
+    EXPAND = auto()
+    SHRINK = auto()
+    RENEW = auto()
+
+
 class NginxManager(AppCommandFeatureProvider):
+    CertbotAction: TypeAlias = CertbotAction
+
     def __init__(self) -> None:
         super().__init__("nginx-manager")
         self._domains_cache: list[str] | None = None
-        self._domain_config_value_cache: str | None = None
 
     def setup(self) -> None:
         pass
 
+    @property
+    def _config_manager(self) -> ConfigManager:
+        return self.app.get_feature(ConfigManager)
+
+    @property
+    def root_domain(self) -> str:
+        return self._config_manager.get_domain_value_str()
+
     @property
     def domains(self) -> list[str]:
         if self._domains_cache is None:
@@ -22,15 +43,13 @@ class NginxManager(AppCommandFeatureProvider):
         return self._domains_cache
 
     @property
-    def _domain_config_name(self) -> str:
-        return self.app.get_feature(ConfigManager).get_domain_item_name()
+    def subdomains(self) -> list[str]:
+        suffix = "." + self.root_domain
+        return [d[: -len(suffix)] for d in self.domains if d.endswith(suffix)]
 
-    def _get_domain_config_value(self) -> str:
-        if self._domain_config_value_cache is None:
-            self._domain_config_value_cache = self.app.get_feature(
-                ConfigManager
-            ).get_item_value_str(self._domain_config_name)
-        return self._domain_config_value_cache
+    @property
+    def _domain_config_name(self) -> str:
+        return self._config_manager.domain_item_name
 
     def _get_domains_from_text(self, text: str) -> set[str]:
         domains: set[str] = set()
@@ -42,17 +61,11 @@ class NginxManager(AppCommandFeatureProvider):
         for match in regex.finditer(text):
             domain_part = match.group(1)
             if domain_variable_str in domain_part:
-                domains.add(
-                    domain_part.replace(
-                        domain_variable_str, self._get_domain_config_value()
-                    )
-                )
+                domains.add(domain_part.replace(domain_variable_str, self.root_domain))
                 continue
             m = brace_domain_variable_regex.search(domain_part)
             if m:
-                domains.add(
-                    domain_part.replace(m.group(0), self._get_domain_config_value())
-                )
+                domains.add(domain_part.replace(m.group(0), self.root_domain))
                 continue
             domains.add(domain_part)
         return domains
@@ -68,13 +81,123 @@ class NginxManager(AppCommandFeatureProvider):
     def _get_domains(self) -> list[str]:
         text = self._get_nginx_conf_template_text()
         domains = list(self._get_domains_from_text(text))
-        domains.remove(self._get_domain_config_value())
-        return [self._get_domain_config_value(), *domains]
+        domains.remove(self.root_domain)
+        return [self.root_domain, *domains]
 
     def _print_domains(self) -> None:
         for domain in self.domains:
             print(domain)
 
+    def _certbot_command(
+        self,
+        action: CertbotAction | str,
+        /,
+        test=False,
+        no_docker=False,
+        *,
+        standalone=None,
+        email=None,
+        agree_tos=True,
+    ) -> str:
+        if isinstance(action, str):
+            action = CertbotAction[action.upper()]
+
+        command_args = []
+
+        add_domain_option = True
+        if action is CertbotAction.CREATE:
+            if standalone is None:
+                standalone = True
+            command_action = "certonly"
+        elif action in [CertbotAction.EXPAND, CertbotAction.SHRINK]:
+            if standalone is None:
+                standalone = False
+            command_action = "certonly"
+        elif action is CertbotAction.RENEW:
+            if standalone is None:
+                standalone = False
+            add_domain_option = False
+            command_action = "renew"
+        else:
+            raise CruInternalError("Invalid certbot action.")
+
+        data_dir = self.app.data_dir.full_path.as_posix()
+
+        if no_docker:
+            command_args.append("certbot")
+        else:
+            command_args.extend(
+                [
+                    "docker run -it --rm --name certbot",
+                    f'-v "{data_dir}/certbot/certs:/etc/letsencrypt"',
+                    f'-v "{data_dir}/certbot/data:/var/lib/letsencrypt"',
+                ]
+            )
+            if standalone:
+                command_args.append('-p "0.0.0.0:80:80"')
+            else:
+                command_args.append(f'-v "{data_dir}/certbot/webroot:/var/www/certbot"')
+
+            command_args.append("certbot/certbot")
+
+        command_args.append(command_action)
+
+        if standalone:
+            command_args.append("--standalone")
+        else:
+            command_args.append("--webroot -w /var/www/certbot")
+
+        if add_domain_option:
+            command_args.append(" ".join([f"-d {domain}" for domain in self.domains]))
+
+        if email is not None:
+            command_args.append(f"--email {email}")
+
+        if agree_tos:
+            command_args.append("--agree-tos")
+
+        if test:
+            command_args.append("--test-cert --dry-run")
+
+        return " ".join(command_args)
+
+    def print_all_certbot_commands(self, test: bool):
+        print("### COMMAND: (standalone) create certs")
+        print(
+            self._certbot_command(
+                CertbotAction.CREATE,
+                test,
+                email=self._config_manager.get_email_value_str_optional(),
+            )
+        )
+        print()
+        print("### COMMAND: (webroot+nginx) expand or shrink certs")
+        print(
+            self._certbot_command(
+                CertbotAction.EXPAND,
+                test,
+                email=self._config_manager.get_email_value_str_optional(),
+            )
+        )
+        print()
+        print("### COMMAND: (webroot+nginx) renew certs")
+        print(
+            self._certbot_command(
+                CertbotAction.RENEW,
+                test,
+                email=self._config_manager.get_email_value_str_optional(),
+            )
+        )
+
+    @property
+    def _cert_path_str(self) -> str:
+        return str(
+            self.app.data_dir.full_path
+            / "certbot/certs/live"
+            / self.root_domain
+            / "fullchain.pem"
+        )
+
     def get_command_info(self):
         return "nginx", "Manage nginx related things."
 
@@ -83,7 +206,73 @@ class NginxManager(AppCommandFeatureProvider):
             dest="nginx_command", required=True, metavar="NGINX_COMMAND"
         )
         _list_parser = subparsers.add_parser("list", help="list domains")
+        certbot_parser = subparsers.add_parser("certbot", help="print certbot commands")
+        certbot_parser.add_argument(
+            "-t", "--test", action="store_true", help="run certbot in test mode"
+        )
 
     def run_command(self, args: Namespace) -> None:
         if args.nginx_command == "list":
             self._print_domains()
+        elif args.nginx_command == "certbot":
+            self.print_all_certbot_commands(args.test)
+
+    def _generate_dns_zone(
+        self,
+        ip: str,
+        /,
+        ttl: str | int = 600,
+        *,
+        enable_mail: bool = True,
+        dkim: str | None = None,
+    ) -> str:
+        # TODO: Not complete and test now.
+        root_domain = self.root_domain
+        result = f"$ORIGIN {root_domain}.\n\n"
+        result += "; A records\n"
+        result += f"@ {ttl} IN A {ip}\n"
+        for subdomain in self.subdomains:
+            result += f"{subdomain} {ttl} IN A {ip}\n"
+
+        if enable_mail:
+            result += "\n; MX records\n"
+            result += f"@ {ttl} IN MX 10 mail.{root_domain}.\n"
+            result += "\n; SPF record\n"
+            result += f'@ {ttl} IN TXT "v=spf1 mx ~all"\n'
+            if dkim is not None:
+                result += "\n; DKIM record\n"
+                result += f'mail._domainkey {ttl} IN TEXT "{dkim}"'
+                result += "\n; DMARC record\n"
+                dmarc_options = [
+                    "v=DMARC1",
+                    "p=none",
+                    f"rua=mailto:dmarc.report@{root_domain}",
+                    f"ruf=mailto:dmarc.report@{root_domain}",
+                    "sp=none",
+                    "ri=86400",
+                ]
+                result += f'_dmarc {ttl} IN TXT "{"; ".join(dmarc_options)}"\n'
+        return result
+
+    def _get_dkim_from_mailserver(self) -> str | None:
+        # TODO: Not complete and test now.
+        dkim_path = (
+            self.app.data_dir.full_path
+            / "dms/config/opendkim/keys"
+            / self.root_domain
+            / "mail.txt"
+        )
+        if not dkim_path.exists():
+            return None
+
+        p = subprocess.run(["sudo", "cat", dkim_path], capture_output=True, check=True)
+        value = ""
+        for match in re.finditer('"(.*)"', p.stdout.decode("utf-8")):
+            value += match.group(1)
+        return value
+
+    def _generate_dns_zone_with_dkim(self, ip: str, /, ttl: str | int = 600) -> str:
+        # TODO: Not complete and test now.
+        return self._generate_dns_zone(
+            ip, ttl, enable_mail=True, dkim=self._get_dkim_from_mailserver()
+        )
diff --git a/tools/cru-py/cru/service/_template.py b/tools/cru-py/cru/service/_template.py
index 9241a1f..ca2135f 100644
--- a/tools/cru-py/cru/service/_template.py
+++ b/tools/cru-py/cru/service/_template.py
@@ -45,10 +45,10 @@ class TemplateManager(AppCommandFeatureProvider):
         for file in CruIterator(self.template_tree.templates).transform(lambda t: t[0]):
             print(file.as_posix())
 
-    def _generate_files(self) -> None:
+    def _generate_files(self, dry_run: bool) -> None:
         config_manager = self.app.get_feature(ConfigManager)
         self.template_tree.generate_to(
-            self.generated_dir.full_path_str, config_manager.get_str_dict()
+            self.generated_dir.full_path_str, config_manager.get_str_dict(), dry_run
         )
 
     def get_command_info(self):
@@ -62,7 +62,10 @@ class TemplateManager(AppCommandFeatureProvider):
         _variables_parser = subparsers.add_parser(
             "variables", help="list variables used in all templates"
         )
-        _generate_parser = subparsers.add_parser("generate", help="generate templates")
+        generate_parser = subparsers.add_parser("generate", help="generate templates")
+        generate_parser.add_argument(
+            "--no-dry-run", action="store_true", help="generate and write target files"
+        )
 
     def run_command(self, args: Namespace) -> None:
         if args.template_command == "list":
@@ -71,4 +74,7 @@ class TemplateManager(AppCommandFeatureProvider):
             for var in self.template_tree.variables:
                 print(var)
         elif args.template_command == "generate":
-            self._generate_files()
+            dry_run = not args.no_dry_run
+            self._generate_files(dry_run)
+            if dry_run:
+                print("Dry run successfully.")
diff --git a/tools/cru-py/cru/service/nginx.py b/tools/cru-py/cru/service/nginx.py
deleted file mode 100644
index ad32cb9..0000000
--- a/tools/cru-py/cru/service/nginx.py
+++ /dev/null
@@ -1,17 +0,0 @@
-import json
-import os
-import re
-import subprocess
-from typing import Literal, Any, cast, ClassVar
-
-
-
-def restart_nginx(force=False) -> bool:
-    if not force:
-        p = subprocess.run(['docker', "container", "ls",
-                            "-f", "name=nginx", "-q"], capture_output=True)
-        container: str = p.stdout.decode("utf-8")
-        if len(container.strip()) == 0:
-            return False
-    subprocess.run(['docker', 'restart', 'nginx'])
-    return True
diff --git a/tools/cru-py/cru/template.py b/tools/cru-py/cru/template.py
index 2b0f1bc..74a5c9a 100644
--- a/tools/cru-py/cru/template.py
+++ b/tools/cru-py/cru/template.py
@@ -137,7 +137,13 @@ class TemplateTree:
             s.update(template.variables)
         return s
 
-    def generate_to(self, destination: str, variables: Mapping[str, str]) -> None:
+    def generate_to(
+        self, destination: str, variables: Mapping[str, str], dry_run: bool
+    ) -> None:
         for file, template in self.templates:
-            with open(os.path.join(destination, file), "w") as f:
-                f.write(template.generate(variables))
+            des = CruPath(destination) / file
+            text = template.generate(variables)
+            if not dry_run:
+                des.parent.mkdir(parents=True, exist_ok=True)
+                with open(des, "w") as f:
+                    f.write(text)
-- 
cgit v1.2.3