├── tests ├── __init__.py ├── test_generator.py ├── test_reset_config.py ├── test_cls.py ├── test_downloader.py ├── test_prompt_helper.py ├── test_detector.py ├── test_loader.py ├── test_fn_show_links.py ├── test_fn_install_docker.py └── test_port_logic.py ├── utils ├── __init__.py ├── networker.py ├── cls.py ├── dumper.py ├── downloader.py ├── fn_show_links.py ├── fn_bye.py ├── fn_reset_config.py ├── detector.py ├── checker.py ├── prompt_helper.py ├── updater.py ├── loader.py ├── helper.py ├── fn_stopStack.py ├── fn_install_docker.py └── fn_multiproxy_tools.py ├── requirements.txt ├── .resources ├── .www │ ├── favicon.ico │ ├── .scripts │ │ └── loadApps.js │ ├── Home.css │ ├── Home.html │ └── index.html ├── .assets │ ├── M4B_logo.png │ ├── default-image.jpg │ └── M4B_logo_small.png └── .files │ └── docker.binfmt.service ├── .gitignore ├── .github ├── SECURITY.md ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── FUNDING.yml ├── FUNDING.yml.template ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md └── workflows │ └── release-on-tag.yml ├── workflow-linux-arm64.dockerfile ├── pyproject.toml ├── examples └── fn_generic.py.template ├── config └── m4b-config.json ├── template └── user-config.json └── main.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | colorama 2 | docker 3 | requests 4 | pyyaml 5 | psutil -------------------------------------------------------------------------------- /.resources/.www/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MRColorR/money4band/HEAD/.resources/.www/favicon.ico -------------------------------------------------------------------------------- /.resources/.assets/M4B_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MRColorR/money4band/HEAD/.resources/.assets/M4B_logo.png -------------------------------------------------------------------------------- /.resources/.assets/default-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MRColorR/money4band/HEAD/.resources/.assets/default-image.jpg -------------------------------------------------------------------------------- /.resources/.assets/M4B_logo_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MRColorR/money4band/HEAD/.resources/.assets/M4B_logo_small.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | m4b_proxy_instances 2 | config/user-config.json 3 | .resources/.www/.images 4 | .resources/.www/.config 5 | .data 6 | logs 7 | docker-compose.yaml 8 | .env 9 | *.txt 10 | !requirements.txt 11 | *.log 12 | *.bak 13 | venv* 14 | tmp/* 15 | tmp.py 16 | *.old 17 | __pycache__ 18 | # pyinstaller folders 19 | build 20 | dist 21 | *.spec -------------------------------------------------------------------------------- /utils/networker.py: -------------------------------------------------------------------------------- 1 | import socket 2 | 3 | 4 | def is_port_in_use(port): 5 | """Check if a port is already in use.""" 6 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: 7 | return sock.connect_ex(("localhost", port)) == 0 8 | 9 | 10 | def find_next_available_port(starting_port): 11 | """Find the next available port starting from the given port.""" 12 | port = starting_port 13 | while is_port_in_use(port): 14 | port += 1 15 | return port 16 | -------------------------------------------------------------------------------- /.resources/.files/docker.binfmt.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Autostart binfmt container as a Service by MRColorR 3 | After=docker.service 4 | Requires=docker.service 5 | 6 | [Service] 7 | TimeoutStartSec=0 8 | Restart=on-failure 9 | StartLimitIntervalSec=10 10 | StartLimitBurst=2 11 | ExecStartPre=-/usr/bin/docker exec %n stop 12 | ExecStartPre=-/usr/bin/docker rm %n 13 | ExecStartPre=-/usr/bin/docker pull tonistiigi/binfmt 14 | ExecStart=/usr/bin/docker run --privileged --rm --name %n tonistiigi/binfmt --install all 15 | 16 | [Install] 17 | WantedBy=default.target -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Project versions that are currently being supported with security updates. 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | latest | :white_check_mark: | 10 | | legacy | :x: | 11 | 12 | ## Reporting a Vulnerability 13 | 14 | In case of security vulnerabilities, contact the developers privately before opening an issue. For normal bugs instead proceed directly to open an issue. 15 | 16 | - If the vulnerability report is accepted, a patch will be produced and a new release will be published with the vulnerability resolved. 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: mrcolorrain 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: mrcolorrain 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: mrcolorrain 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /tests/test_generator.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from utils.generator import generate_device_name, generate_uuid, validate_uuid 4 | 5 | 6 | class TestGeneratorFunctions(unittest.TestCase): 7 | def test_validate_uuid(self): 8 | valid_uuid = "1234567890abcdef1234567890abcdef" 9 | invalid_uuid = "12345" 10 | self.assertTrue(validate_uuid(valid_uuid, 32)) 11 | self.assertFalse(validate_uuid(invalid_uuid, 32)) 12 | 13 | def test_generate_uuid(self): 14 | uuid = generate_uuid(32) 15 | self.assertEqual(len(uuid), 32) 16 | 17 | def test_generate_device_name(self): 18 | adjectives = ["swift", "brave"] 19 | animals = ["panther", "eagle"] 20 | device_name = generate_device_name(adjectives, animals) 21 | self.assertIn(device_name.split("_")[0], adjectives) 22 | self.assertIn(device_name.split("_")[1], animals) 23 | 24 | 25 | if __name__ == "__main__": 26 | unittest.main() 27 | -------------------------------------------------------------------------------- /.github/FUNDING.yml.template: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [YOUR_GITHUB_USERNAME] # Replace with your GitHub username if you have GitHub Sponsors enabled 4 | 5 | patreon: YOUR_PATREON_USERNAME # Replace with your Patreon username 6 | 7 | open_collective: YOUR_OPEN_COLLECTIVE # Replace with your Open Collective page name 8 | 9 | ko_fi: YOUR_KO_FI_HANDLE # Replace with your Ko-fi page 10 | 11 | tidelift: YOUR_TIDELIFT_PROJECT # Replace with your Tidelift project name for your package 12 | 13 | community_bridge: YOUR_COMMUNITY_BRIDGE_PROJECT # Replace with your Community Bridge project name 14 | 15 | liberapay: YOUR_LIBERAPAY_USERNAME # Replace with your Liberapay username 16 | 17 | issuehunt: YOUR_ISSUEHUNT_USERNAME # Replace with your IssueHunt username 18 | 19 | otechie: YOUR_OTECHIE_USERNAME # Replace with your Otechie username 20 | 21 | custom: ['YOUR_CUSTOM_FUNDING_URL'] # Replace with any other custom funding URL(s) 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | [...] 14 | 15 | **Device (please complete the following information):** 16 | - Type: [e.g. PC Desktop / Server / Raspberry Pi 3 B+/ Laptop ] 17 | - OS details: [e.g. Linux Ubuntu / Windows / Mac ] 18 | - Platform: [e.g. i386 (32bit) / x86_64 or amd64 (64bit) / arm64 (64bit) ] 19 | - Version [e.g. Ubuntu 22.10 / Windows 11 / macOS Ventura ] 20 | 21 | **Docker** 22 | - Docker Compose version: [use `docker compose version` to discover installed version] 23 | - Docker version: [use `docker version` to discover installed version, write here the docker server version, e.g. Docker Desktop 14.13.1] 24 | 25 | **To Reproduce** 26 | Steps to reproduce the behavior: 27 | 1. Go to '...' 28 | 2. Click on '....' 29 | 3. See error 30 | 31 | **Expected behavior** 32 | A clear and concise description of what you expected to happen. 33 | 34 | **Screenshots** 35 | If applicable, add screenshots to help explain your problem. 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /workflow-linux-arm64.dockerfile: -------------------------------------------------------------------------------- 1 | # Use Python 3.12 as the base image 2 | FROM python:3.12 3 | 4 | # Set environment variables for non-interactive installation 5 | ENV DEBIAN_FRONTEND=noninteractive 6 | 7 | # Install necessary packages 8 | RUN apt-get update && \ 9 | apt-get install -y \ 10 | build-essential \ 11 | libssl-dev \ 12 | libffi-dev \ 13 | python3-setuptools \ 14 | git \ 15 | curl \ 16 | && apt-get clean 17 | 18 | # Install PyInstaller 19 | RUN pip install pyinstaller 20 | 21 | # Set the working directory 22 | WORKDIR /app 23 | 24 | # Copy project files into the container 25 | COPY . . 26 | 27 | # Install Python dependencies 28 | RUN pip install -r requirements.txt 29 | 30 | # Build the project using PyInstaller 31 | CMD ["pyinstaller", "--onedir", "--name", "Money4Band", "main.py", "--hidden-import", "colorama", "--hidden-import", "docker", "--hidden-import", "requests", "--hidden-import", "pyyaml", "--hidden-import", "psutil", "--hidden-import", "yaml", "--hidden-import", "secrets", "--add-data", ".resources:.resources", "--add-data", "config:config", "--add-data", "utils:utils", "--add-data", "template:template", "--add-data", "LICENSE:LICENSE", "--add-data", "README.md:README.md", "--contents-directory", ".", "-y"] 32 | -------------------------------------------------------------------------------- /tests/test_reset_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | import unittest 4 | 5 | from utils.fn_reset_config import main as reset_config_main 6 | 7 | 8 | class TestResetConfig(unittest.TestCase): 9 | def setUp(self): 10 | """Set up test environment using temporary directories.""" 11 | self.test_dir = tempfile.TemporaryDirectory() 12 | self.src_dir = os.path.join(self.test_dir.name, "template") 13 | self.dest_dir = os.path.join(self.test_dir.name, "config") 14 | os.makedirs(self.src_dir, exist_ok=True) 15 | os.makedirs(self.dest_dir, exist_ok=True) 16 | with open(os.path.join(self.src_dir, "test-config.json"), "w") as f: 17 | f.write('{"test_key": "test_value"}') 18 | 19 | def tearDown(self): 20 | """Clean up test environment by removing temporary directories.""" 21 | self.test_dir.cleanup() 22 | 23 | def test_reset_config(self): 24 | """Test the reset_config function.""" 25 | src_path = os.path.join(self.src_dir, "test-config.json") 26 | dest_path = os.path.join(self.dest_dir, "test-config.json") 27 | 28 | reset_config_main( 29 | app_config=None, 30 | m4b_config=None, 31 | user_config=None, 32 | src_path=src_path, 33 | dest_path=dest_path, 34 | ) 35 | 36 | self.assertTrue(os.path.exists(dest_path)) 37 | with open(dest_path) as f: 38 | data = f.read() 39 | self.assertIn("test_key", data) 40 | self.assertIn("test_value", data) 41 | 42 | 43 | if __name__ == "__main__": 44 | unittest.main() 45 | -------------------------------------------------------------------------------- /tests/test_cls.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | from unittest.mock import patch 4 | 5 | from utils.cls import cls 6 | 7 | 8 | class TestClsFunction(unittest.TestCase): 9 | @patch("os.system") 10 | @patch("utils.cls.logging.info") 11 | @patch("utils.cls.logging.error") 12 | def test_cls_success(self, mock_logging_error, mock_logging_info, mock_os_system): 13 | """ 14 | Test that the cls function clears the console and logs the success message. 15 | """ 16 | # Set up the os.system mock to return 0 (success) 17 | mock_os_system.return_value = 0 18 | 19 | cls() 20 | 21 | mock_os_system.assert_called_once_with("cls" if os.name == "nt" else "clear") 22 | mock_logging_info.assert_called_once_with("Console cleared successfully") 23 | mock_logging_error.assert_not_called() 24 | 25 | @patch("os.system", side_effect=Exception("Mocked error")) 26 | @patch("utils.cls.logging.info") 27 | @patch("utils.cls.logging.error") 28 | def test_cls_failure(self, mock_logging_error, mock_logging_info, mock_os_system): 29 | """ 30 | Test that the cls function handles exceptions and logs the error message. 31 | """ 32 | with self.assertRaises(Exception) as context: 33 | cls() 34 | 35 | mock_os_system.assert_called_once_with("cls" if os.name == "nt" else "clear") 36 | mock_logging_info.assert_not_called() 37 | mock_logging_error.assert_called_once_with( 38 | "Error clearing console: Mocked error" 39 | ) 40 | self.assertEqual(str(context.exception), "Mocked error") 41 | 42 | 43 | if __name__ == "__main__": 44 | unittest.main() 45 | -------------------------------------------------------------------------------- /tests/test_downloader.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import MagicMock, mock_open, patch 3 | 4 | import requests 5 | 6 | from utils.downloader import download_file 7 | 8 | 9 | class TestDownloadFile(unittest.TestCase): 10 | @patch("utils.downloader.requests.get") 11 | def test_download_file_success(self, mock_get): 12 | """ 13 | Test successful download of a file. 14 | """ 15 | # Mock the response 16 | mock_response = MagicMock() 17 | mock_response.iter_content = lambda chunk_size: [b"test data"] 18 | mock_response.raise_for_status = MagicMock() 19 | mock_get.return_value = mock_response 20 | 21 | # Mock the open function 22 | with patch("builtins.open", mock_open()) as mocked_file: 23 | download_file("http://example.com/testfile", "/tmp/testfile") 24 | mocked_file.assert_called_once_with("/tmp/testfile", "wb") 25 | mocked_file().write.assert_called_once_with(b"test data") 26 | 27 | @patch("utils.downloader.logging.error") 28 | @patch("utils.downloader.requests.get") 29 | def test_download_file_failure(self, mock_get, mock_logging_error): 30 | """ 31 | Test download failure due to a request exception. 32 | """ 33 | # Mock the response to raise an exception 34 | mock_get.side_effect = requests.RequestException("Error") 35 | 36 | with self.assertRaises(requests.RequestException): 37 | download_file("http://example.com/testfile", "/tmp/testfile") 38 | 39 | mock_logging_error.assert_called_once_with( 40 | "An error occurred while downloading the file from http://example.com/testfile: Error" 41 | ) 42 | 43 | 44 | if __name__ == "__main__": 45 | unittest.main() 46 | -------------------------------------------------------------------------------- /tests/test_prompt_helper.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch 3 | 4 | from utils.prompt_helper import ask_email, ask_question_yn, ask_string, ask_uuid 5 | 6 | 7 | class TestPromptHelper(unittest.TestCase): 8 | @patch("builtins.input", return_value="test@example.com") 9 | def test_ask_email_valid(self, mock_input): 10 | self.assertEqual(ask_email("Enter email:"), "test@example.com") 11 | 12 | @patch("builtins.input", side_effect=["", "test@example.com"]) 13 | def test_ask_email_empty_then_valid(self, mock_input): 14 | self.assertEqual(ask_email("Enter email:"), "test@example.com") 15 | 16 | @patch("builtins.input", return_value="") 17 | def test_ask_string_empty_allowed(self, mock_input): 18 | self.assertEqual(ask_string("Enter string:", empty_allowed=True), "") 19 | 20 | @patch("builtins.input", return_value="non-empty string") 21 | def test_ask_string_not_empty(self, mock_input): 22 | self.assertEqual(ask_string("Enter string:"), "non-empty string") 23 | 24 | @patch("builtins.input", return_value="y") 25 | def test_ask_question_yn_yes(self, mock_input): 26 | self.assertTrue(ask_question_yn("Continue?")) 27 | 28 | @patch("builtins.input", return_value="n") 29 | def test_ask_question_yn_no(self, mock_input): 30 | self.assertFalse(ask_question_yn("Continue?")) 31 | 32 | @patch("builtins.input", return_value="abcd1234abcd1234abcd1234abcd1234") 33 | def test_ask_uuid_valid(self, mock_input): 34 | self.assertEqual( 35 | ask_uuid("Enter UUID:", 32), "abcd1234abcd1234abcd1234abcd1234" 36 | ) 37 | 38 | @patch("builtins.input", side_effect=["", "abcd1234abcd1234abcd1234abcd1234"]) 39 | def test_ask_uuid_empty_then_valid(self, mock_input): 40 | self.assertEqual( 41 | ask_uuid("Enter UUID:", 32), "abcd1234abcd1234abcd1234abcd1234" 42 | ) 43 | 44 | 45 | if __name__ == "__main__": 46 | unittest.main() 47 | -------------------------------------------------------------------------------- /utils/cls.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import os 4 | 5 | 6 | def cls(): 7 | """ 8 | Clear the console. 9 | """ 10 | try: 11 | os.system("cls" if os.name == "nt" else "clear") 12 | logging.info("Console cleared successfully") 13 | except Exception as e: 14 | logging.error(f"Error clearing console: {str(e)}") 15 | raise 16 | 17 | 18 | if __name__ == "__main__": 19 | # Get the script absolute path and name 20 | script_dir = os.path.dirname(os.path.abspath(__file__)) 21 | script_name = os.path.basename(__file__) 22 | 23 | # Parse command-line arguments 24 | parser = argparse.ArgumentParser( 25 | description=f"Run the {script_name} module standalone." 26 | ) 27 | parser.add_argument( 28 | "--log-dir", 29 | default=os.path.join(script_dir, "logs"), 30 | help="Set the logging directory", 31 | ) 32 | parser.add_argument( 33 | "--log-file", default=f"{script_name}.log", help="Set the logging file name" 34 | ) 35 | parser.add_argument( 36 | "--log-level", 37 | choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], 38 | default="INFO", 39 | help="Set the logging level", 40 | ) 41 | args = parser.parse_args() 42 | 43 | # Set logging level based on command-line arguments 44 | log_level = getattr(logging, args.log_level.upper(), None) 45 | if not isinstance(log_level, int): 46 | raise ValueError(f"Invalid log level: {args.log_level}") 47 | 48 | # Start logging 49 | os.makedirs(args.log_dir, exist_ok=True) 50 | logging.basicConfig( 51 | filename=os.path.join(args.log_dir, args.log_file), 52 | format="%(asctime)s - [%(levelname)s] - %(message)s", 53 | datefmt="%Y-%m-%d %H:%M:%S", 54 | level=log_level, 55 | ) 56 | 57 | logging.info(f"Starting {script_name} script...") 58 | 59 | # Test the function 60 | logging.info(f"Testing {script_name} function...") 61 | cls() 62 | logging.info(f"{script_name} test complete") 63 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | 3 | name = "money4band" 4 | description = "Multi-app Docker orchestrator for passive income apps" 5 | version = "4.11.1" 6 | authors = [ 7 | { name = "MRColorR" } 8 | ] 9 | requires-python = ">=3.10" 10 | dependencies = [ 11 | "colorama", 12 | "docker", 13 | "requests", 14 | "pyyaml", 15 | "psutil" 16 | ] 17 | 18 | [project.optional-dependencies] 19 | dev = [ 20 | "ruff>=0.12.0", 21 | "pytest>=8.0.0", 22 | "pytest-cov>=4.1.0", 23 | "pytest-mock>=3.12.0", 24 | "pytest-asyncio>=0.23.0", 25 | ] 26 | 27 | # Tool configurations 28 | 29 | [tool.uv] 30 | # Optional: add uv-specific settings here 31 | 32 | # Ruff configuration 33 | [tool.ruff] 34 | line-length = 88 35 | 36 | [tool.ruff.lint] 37 | # Enable specific linting rules: 38 | # E - pycodestyle errors 39 | # W - pycodestyle warnings (Omit if using formatter to avoid rules overlap) 40 | # F - pyflakes (undefined names, unused imports) 41 | # I - isort import sorting 42 | # B - flake8-bugbear (likely bugs, design issues) 43 | # UP - pyupgrade (syntax modernization) 44 | # C4 - flake8-comprehensions 45 | # PIE - miscellaneous lints 46 | # TID - flake8-tidy-imports 47 | # S - flake8-bandit (security checks) 48 | # N - pep8-naming (naming conventions) 49 | # T20 - flake8-print (detect print/debug) 50 | # PL - pylint subset (advanced static analysis) 51 | select = [ 52 | "E", 53 | "F", 54 | "I", 55 | "B", 56 | "UP", 57 | "C4", 58 | "PIE", 59 | "TID", 60 | "S", 61 | "N", 62 | "T20", 63 | "PL", 64 | ] 65 | 66 | # Ignore rules that conflict with formatter or are too strict: 67 | # W191, E111, E114, E117 - indentation errors handled by formatter 68 | # D206, D300 - docstring styles that can conflict 69 | # Q000, Q001, Q002, Q003 - quote styles managed by formatter 70 | # COM812, COM819 - trailing comma conflicts 71 | # ISC002 - implicit string concat warnings 72 | # T201 - print statements are needed for CLI user interaction 73 | ignore = [ 74 | "W191", "E111", "E114", "E117", 75 | "D206", "D300", 76 | "Q000", "Q001", "Q002", "Q003", 77 | "COM812", "COM819", 78 | "ISC002", 79 | "T201" 80 | ] 81 | 82 | # Add other tool configurations as needed 83 | -------------------------------------------------------------------------------- /tests/test_detector.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch 3 | 4 | from utils.detector import detect_architecture, detect_os 5 | 6 | 7 | class TestDetector(unittest.TestCase): 8 | @patch("utils.loader.load_json_config") 9 | @patch("platform.system", return_value="Linux") 10 | def test_detect_os(self, mock_platform_system, mock_load_json_config): 11 | """ 12 | Test detecting the operating system type. 13 | """ 14 | mock_load_json_config.return_value = { 15 | "system": { 16 | "os_map": { 17 | "win32nt": "Windows", 18 | "windows_nt": "Windows", 19 | "windows": "Windows", 20 | "linux": "Linux", 21 | "darwin": "MacOS", 22 | "macos": "MacOS", 23 | "macosx": "MacOS", 24 | "mac": "MacOS", 25 | "osx": "MacOS", 26 | "cygwin": "Cygwin", 27 | "mingw": "MinGw", 28 | "msys": "Msys", 29 | "freebsd": "FreeBSD", 30 | } 31 | } 32 | } 33 | 34 | expected_os_type = "Linux" 35 | result = detect_os({"system": {"os_map": {"linux": "Linux"}}}) 36 | self.assertEqual(result, {"os_type": expected_os_type}) 37 | mock_platform_system.assert_called_once() 38 | 39 | @patch("utils.loader.load_json_config") 40 | @patch("platform.machine", return_value="x86_64") 41 | def test_detect_architecture(self, mock_platform_machine, mock_load_json_config): 42 | """ 43 | Test detecting the system architecture. 44 | """ 45 | mock_load_json_config.return_value = { 46 | "system": { 47 | "arch_map": { 48 | "x86_64": "amd64", 49 | "amd64": "amd64", 50 | "aarch64": "arm64", 51 | "arm64": "arm64", 52 | } 53 | } 54 | } 55 | 56 | expected_arch = "x86_64" 57 | expected_dkarch = "amd64" 58 | result = detect_architecture({"system": {"arch_map": {"x86_64": "amd64"}}}) 59 | self.assertEqual(result, {"arch": expected_arch, "dkarch": expected_dkarch}) 60 | mock_platform_machine.assert_called_once() 61 | 62 | 63 | if __name__ == "__main__": 64 | unittest.main() 65 | -------------------------------------------------------------------------------- /.resources/.www/.scripts/loadApps.js: -------------------------------------------------------------------------------- 1 | window.onload = () => { 2 | fetch('.config/app-config.json') 3 | .then(response => response.json()) 4 | .then(config => { 5 | const tableBody = document.querySelector('.u-table-body'); 6 | 7 | // Function to create rows for each app category 8 | const createAppRows = (appCategory, categoryTitle) => { 9 | const categoryHeader = document.createElement('tr'); 10 | const categoryTitleCell = document.createElement('td'); 11 | categoryTitleCell.colSpan = 3; // Spanning across three columns 12 | categoryTitleCell.style.fontWeight = 'bold'; // Making the title bold 13 | categoryTitleCell.textContent = categoryTitle; 14 | categoryHeader.appendChild(categoryTitleCell); 15 | tableBody.appendChild(categoryHeader); 16 | 17 | appCategory.forEach((app, index) => { 18 | const tr = document.createElement('tr'); 19 | tr.style.height = '52px'; 20 | 21 | const tdName = document.createElement('td'); 22 | tdName.className = 'u-border-2 u-border-grey-10 u-border-no-left u-border-no-right u-table-cell'; 23 | tdName.textContent = app.name; 24 | 25 | const tdDashboard = document.createElement('td'); 26 | tdDashboard.className = 'u-border-2 u-border-grey-10 u-border-no-left u-border-no-right u-table-cell'; 27 | tdDashboard.innerHTML = `Dashboard`; 28 | 29 | const tdInvite = document.createElement('td'); 30 | tdInvite.className = 'u-border-2 u-border-grey-10 u-border-no-left u-border-no-right u-table-cell'; 31 | tdInvite.innerHTML = `Invite friends`; 32 | 33 | tr.appendChild(tdName); 34 | tr.appendChild(tdDashboard); 35 | tr.appendChild(tdInvite); 36 | 37 | tableBody.appendChild(tr); 38 | }); 39 | }; 40 | 41 | // Creating rows for each app category 42 | createAppRows(config.apps, "Apps"); 43 | createAppRows(config['extra-apps'], "Extra-apps"); 44 | createAppRows(config['removed-apps'], "Removed-apps"); 45 | }) 46 | .catch(error => console.error('Error:', error)); 47 | }; -------------------------------------------------------------------------------- /utils/dumper.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import json 3 | import logging 4 | import os 5 | from typing import Any 6 | 7 | 8 | def write_json(data: dict[str, Any], filename: str) -> None: 9 | """ 10 | Write data to a JSON file. 11 | 12 | Arguments: 13 | data -- the data to write 14 | filename -- the file to write the data to 15 | """ 16 | try: 17 | with open(filename, "w") as json_file: 18 | json.dump(data, json_file, indent=4) 19 | logging.info(f"Data written to {filename} successfully!") 20 | except Exception as e: 21 | logging.error(f"Error writing to {filename}: {e}") 22 | raise 23 | 24 | 25 | if __name__ == "__main__": 26 | # Get the script absolute path and name 27 | script_dir = os.path.dirname(os.path.abspath(__file__)) 28 | script_name = os.path.basename(__file__) 29 | 30 | # Parse command-line arguments 31 | parser = argparse.ArgumentParser(description="Write data to a JSON file.") 32 | parser.add_argument( 33 | "--data", type=str, required=True, help="The data to write in JSON format" 34 | ) 35 | parser.add_argument( 36 | "--filename", type=str, required=True, help="The filename to write the data to" 37 | ) 38 | parser.add_argument( 39 | "--log-dir", 40 | default=os.path.join(script_dir, "logs"), 41 | help="Set the logging directory", 42 | ) 43 | parser.add_argument( 44 | "--log-file", default=f"{script_name}.log", help="Set the logging file name" 45 | ) 46 | parser.add_argument( 47 | "--log-level", 48 | choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], 49 | default="INFO", 50 | help="Set the logging level", 51 | ) 52 | args = parser.parse_args() 53 | 54 | # Set logging level based on command-line arguments 55 | log_level = getattr(logging, args.log_level.upper(), None) 56 | if not isinstance(log_level, int): 57 | raise ValueError(f"Invalid log level: {args.log_level}") 58 | 59 | # Start logging 60 | os.makedirs(args.log_dir, exist_ok=True) 61 | logging.basicConfig( 62 | filename=os.path.join(args.log_dir, args.log_file), 63 | format="%(asctime)s - [%(levelname)s] - %(message)s", 64 | datefmt="%Y-%m-%d %H:%M:%S", 65 | level=log_level, 66 | ) 67 | 68 | logging.info(f"Starting {script_name} script...") 69 | 70 | try: 71 | # Load the data from the command-line argument 72 | data = json.loads(args.data) 73 | 74 | # Write data to the specified filename 75 | write_json(data, args.filename) 76 | 77 | logging.info(f"{script_name} script completed successfully") 78 | except Exception as e: 79 | logging.error(f"An unexpected error occurred: {str(e)}") 80 | raise 81 | -------------------------------------------------------------------------------- /tests/test_loader.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | from unittest.mock import MagicMock, mock_open, patch 4 | 5 | from utils.loader import ( 6 | load_json_config, 7 | load_module_from_file, 8 | load_modules_from_directory, 9 | ) 10 | 11 | 12 | class TestModuleLoader(unittest.TestCase): 13 | @patch("builtins.open", new_callable=mock_open, read_data='{"key": "value"}') 14 | def test_load_json_config_file(self, mock_file): 15 | """ 16 | Test loading JSON config from a file. 17 | """ 18 | config_path = "/path/to/config.json" 19 | expected_config = {"key": "value"} 20 | 21 | config = load_json_config(config_path) 22 | self.assertEqual(config, expected_config) 23 | mock_file.assert_called_once_with(config_path, "r") 24 | 25 | def test_load_json_config_dict(self): 26 | """ 27 | Test loading JSON config from a dictionary. 28 | """ 29 | config_dict = {"key": "value"} 30 | 31 | config = load_json_config(config_dict) 32 | self.assertEqual(config, config_dict) 33 | 34 | def test_load_json_config_invalid_type(self): 35 | """ 36 | Test loading JSON config with an invalid type. 37 | """ 38 | with self.assertRaises(ValueError): 39 | load_json_config(123) 40 | 41 | @patch("importlib.util.spec_from_file_location") 42 | @patch("importlib.util.module_from_spec") 43 | def test_load_module_from_file( 44 | self, mock_module_from_spec, mock_spec_from_file_location 45 | ): 46 | """ 47 | Test dynamically loading a module from a file. 48 | """ 49 | mock_spec = MagicMock() 50 | mock_spec.loader.exec_module = MagicMock() 51 | mock_spec_from_file_location.return_value = mock_spec 52 | mock_module = MagicMock() 53 | mock_module_from_spec.return_value = mock_module 54 | 55 | module_name = "test_module" 56 | file_path = "/path/to/module.py" 57 | 58 | module = load_module_from_file(module_name, file_path) 59 | self.assertEqual(module, mock_module) 60 | mock_spec_from_file_location.assert_called_once_with(module_name, file_path) 61 | mock_spec.loader.exec_module.assert_called_once_with(mock_module) 62 | 63 | @patch( 64 | "os.listdir", 65 | return_value=["module1.py", "module2.py", "__init__.py", "not_a_module.txt"], 66 | ) 67 | @patch("utils.loader.load_module_from_file") 68 | def test_load_modules_from_directory( 69 | self, mock_load_module_from_file, mock_listdir 70 | ): 71 | """ 72 | Test dynamically loading all modules in a directory. 73 | """ 74 | mock_load_module_from_file.side_effect = lambda name, path: {name: path} 75 | 76 | directory_path = "/path/to/modules" 77 | modules = load_modules_from_directory(directory_path) 78 | 79 | expected_modules = { 80 | "module1": {"module1": os.path.join(directory_path, "module1.py")}, 81 | "module2": {"module2": os.path.join(directory_path, "module2.py")}, 82 | } 83 | self.assertEqual(modules, expected_modules) 84 | 85 | 86 | if __name__ == "__main__": 87 | unittest.main() 88 | -------------------------------------------------------------------------------- /utils/downloader.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import os 4 | 5 | import requests 6 | 7 | 8 | def download_file(url: str, dest_path: str) -> None: 9 | """ 10 | Download a file from a given URL and save it to the specified destination path. 11 | 12 | Args: 13 | url (str): The URL of the file to download. 14 | dest_path (str): The local path where the file will be saved. 15 | 16 | Raises: 17 | requests.RequestException: If there is an issue with the request. 18 | """ 19 | try: 20 | logging.info(f"Starting download from {url}") 21 | response = requests.get(url, stream=True) 22 | response.raise_for_status() 23 | 24 | # Create the directory if it doesn't exist 25 | os.makedirs(os.path.dirname(dest_path), exist_ok=True) 26 | 27 | with open(dest_path, "wb") as file: 28 | for chunk in response.iter_content(chunk_size=8192): 29 | if chunk: 30 | file.write(chunk) 31 | logging.info(f"File downloaded successfully from {url} to {dest_path}") 32 | except requests.RequestException as e: 33 | logging.error( 34 | f"An error occurred while downloading the file from {url}: {str(e)}" 35 | ) 36 | raise 37 | except OSError as e: 38 | logging.error( 39 | f"An error occurred while writing the file to {dest_path}: {str(e)}" 40 | ) 41 | raise 42 | 43 | 44 | if __name__ == "__main__": 45 | # Get the script absolute path and name 46 | script_dir = os.path.dirname(os.path.abspath(__file__)) 47 | script_name = os.path.basename(__file__) 48 | 49 | # Parse command-line arguments 50 | parser = argparse.ArgumentParser( 51 | description=f"Run the {script_name} module standalone." 52 | ) 53 | parser.add_argument( 54 | "--url", type=str, required=True, help="URL of the file to download" 55 | ) 56 | parser.add_argument( 57 | "--dest-path", 58 | type=str, 59 | required=True, 60 | help="Destination path where the file will be saved", 61 | ) 62 | parser.add_argument( 63 | "--log-dir", 64 | default=os.path.join(script_dir, "logs"), 65 | help="Set the logging directory", 66 | ) 67 | parser.add_argument( 68 | "--log-file", default=f"{script_name}.log", help="Set the logging file name" 69 | ) 70 | parser.add_argument( 71 | "--log-level", 72 | choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], 73 | default="INFO", 74 | help="Set the logging level", 75 | ) 76 | args = parser.parse_args() 77 | 78 | # Set logging level based on command-line arguments 79 | log_level = getattr(logging, args.log_level.upper(), None) 80 | if not isinstance(log_level, int): 81 | raise ValueError(f"Invalid log level: {args.log_level}") 82 | 83 | # Start logging 84 | os.makedirs(args.log_dir, exist_ok=True) 85 | logging.basicConfig( 86 | filename=os.path.join(args.log_dir, args.log_file), 87 | format="%(asctime)s - [%(levelname)s] - %(message)s", 88 | datefmt="%Y-%m-%d %H:%M:%S", 89 | level=log_level, 90 | ) 91 | 92 | logging.info(f"Starting {script_name} script...") 93 | 94 | try: 95 | # Call the download_file function 96 | download_file(args.url, args.dest_path) 97 | logging.info(f"{script_name} script completed successfully") 98 | except Exception as e: 99 | logging.error(f"An unexpected error occurred: {str(e)}") 100 | raise 101 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Welcome to Mooney4Band contributing guide 2 | Thank you for investing your time in contributing to this project! 3 | 4 | - Read our Code of Conduct to keep our community approachable and respectable. 5 | 6 | - In this guide you will get an overview of the contribution workflow from opening an issue, creating a PR, reviewing, and merging the PR. 7 | 8 | ## New contributor guide 9 | - To get an overview of the project, read the README. 10 | 11 | ## Getting Started 12 | ### Issues 13 | #### Create a new issue 14 | 15 | If you spot a problem with the docs, search if an issue already exists. If a related issue doesn't exist, you can open a new issue using a relevant issue form. 16 | 17 | #### Solve an issue 18 | 19 | Scan through our existing issues to find one that interests you. You can narrow down the search using labels as filters. As a general rule, we don’t assign issues to anyone. If you find an issue to work on, you are welcome to open a PR with a fix. 20 | 21 | ### Make Changes 22 | 23 | #### Make changes locally 24 | 25 | 1. [Install Git LFS](https://docs.github.com/en/github/managing-large-files/versioning-large-files/installing-git-large-file-storage). 26 | 27 | 2. Fork the repository. 28 | - Using GitHub Desktop: 29 | - [Getting started with GitHub Desktop](https://docs.github.com/en/desktop/installing-and-configuring-github-desktop/getting-started-with-github-desktop) will guide you through setting up Desktop. 30 | - Once Desktop is set up, you can use it to [fork the repo](https://docs.github.com/en/desktop/contributing-and-collaborating-using-github-desktop/cloning-and-forking-repositories-from-github-desktop)! 31 | 32 | - Using the command line: 33 | - [Fork the repo](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo#fork-an-example-repository) so that you can make your changes without affecting the original project until you're ready to merge them. 34 | 35 | 3. Create a working branch and start with your changes! 36 | 37 | ### Commit your update 38 | 39 | Commit the changes once you are happy with them. Don't forget to [self-review](/contributing/self-review.md) to speed up the review process:zap:. 40 | 41 | ### Pull Request 42 | 43 | When you're finished with the changes, create a pull request, also known as a PR. 44 | - Fill the "Ready for review" template so that we can review your PR. This template helps reviewers understand your changes as well as the purpose of your pull request. 45 | - Don't forget to [link PR to issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue) if you are solving one. 46 | - Enable the checkbox to [allow maintainer edits](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/allowing-changes-to-a-pull-request-branch-created-from-a-fork) so the branch can be updated for a merge. 47 | Once you submit your PR, a team member will review your proposal. We may ask questions or request additional information. 48 | - We may ask for changes to be made before a PR can be merged, either using [suggested changes](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/incorporating-feedback-in-your-pull-request) or pull request comments. You can apply suggested changes directly through the UI. You can make any other changes in your fork, then commit them to your branch. 49 | - As you update your PR and apply changes, mark each conversation as [resolved](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/commenting-on-a-pull-request#resolving-conversations). 50 | - If you run into any merge issues, checkout this [git tutorial](https://github.com/skills/resolve-merge-conflicts) to help you resolve merge conflicts and other issues. 51 | 52 | ### Your PR is merged! 53 | 54 | Congratulations :tada::tada: The team thanks you :sparkles:. 55 | 56 | Once your PR is merged, your contributions will be publicly visible.. 57 | -------------------------------------------------------------------------------- /utils/fn_show_links.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import json 3 | import logging 4 | import os 5 | 6 | # Ensure the parent directory is in the sys.path 7 | import sys 8 | 9 | from colorama import Back, Fore, Style, just_fix_windows_console 10 | 11 | from utils.cls import cls 12 | from utils.loader import load_json_config 13 | 14 | script_dir = os.path.dirname(os.path.abspath(__file__)) 15 | parent_dir = os.path.dirname(script_dir) 16 | if parent_dir not in sys.path: 17 | sys.path.append(parent_dir) 18 | # Import the module from the parent directory 19 | 20 | 21 | def fn_show_links(app_config: dict) -> None: 22 | """ 23 | Show the links of the apps. 24 | 25 | Arguments: 26 | app_config -- the app config dictionary 27 | """ 28 | try: 29 | logging.info("Showing links of the apps") 30 | cls() 31 | just_fix_windows_console() 32 | 33 | # Iterate over all categories and apps 34 | for category, apps in app_config.items(): 35 | if not isinstance(apps, list): 36 | logging.warning(f"Skipping {category} as it is not a list") 37 | continue 38 | 39 | if len(apps) == 0: 40 | continue 41 | 42 | print(f"{Back.YELLOW}---{category.upper()}---{Back.RESET}") 43 | for app in apps: 44 | print( 45 | f"{Fore.GREEN}{app['name'].upper()}: {Fore.CYAN}{app['link']}{Style.RESET_ALL}" 46 | ) 47 | print("\n") 48 | 49 | print("Info: Use CTRL+Click to open links or copy them") 50 | input("Press Enter to go back to main menu") 51 | except Exception as e: 52 | logging.error(f"An error occurred in fn_show_links: {str(e)}") 53 | raise 54 | 55 | 56 | def main(app_config_path: str, m4b_config_path: str, user_config_path: str) -> None: 57 | """ 58 | Main function to call the fn_show_links function. 59 | 60 | Arguments: 61 | app_config_path -- the path to the app configuration file 62 | m4b_config_path -- the path to the m4b configuration file 63 | user_config_path -- the path to the user configuration file 64 | """ 65 | app_config = load_json_config(app_config_path) 66 | fn_show_links(app_config) 67 | 68 | 69 | if __name__ == "__main__": 70 | # Get the script absolute path and name 71 | script_dir = os.path.dirname(os.path.abspath(__file__)) 72 | script_name = os.path.basename(__file__) 73 | 74 | # Parse command-line arguments 75 | parser = argparse.ArgumentParser(description="Run the module standalone.") 76 | parser.add_argument( 77 | "--app-config", type=str, required=True, help="Path to app_config JSON file" 78 | ) 79 | parser.add_argument( 80 | "--m4b-config", type=str, required=False, help="Path to m4b_config JSON file" 81 | ) 82 | parser.add_argument( 83 | "--user-config", type=str, required=False, help="Path to user_config JSON file" 84 | ) 85 | parser.add_argument( 86 | "--log-dir", 87 | default=os.path.join(script_dir, "logs"), 88 | help="Set the logging directory", 89 | ) 90 | parser.add_argument( 91 | "--log-file", default=f"{script_name}.log", help="Set the logging file name" 92 | ) 93 | parser.add_argument( 94 | "--log-level", 95 | choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], 96 | default="INFO", 97 | help="Set the logging level", 98 | ) 99 | args = parser.parse_args() 100 | 101 | # Set logging level based on command-line arguments 102 | log_level = getattr(logging, args.log_level.upper(), None) 103 | if not isinstance(log_level, int): 104 | raise ValueError(f"Invalid log level: {args.log_level}") 105 | 106 | # Start logging 107 | os.makedirs(args.log_dir, exist_ok=True) 108 | logging.basicConfig( 109 | filename=os.path.join(args.log_dir, args.log_file), 110 | format="%(asctime)s - [%(levelname)s] - %(message)s", 111 | datefmt="%Y-%m-%d %H:%M:%S", 112 | level=log_level, 113 | ) 114 | 115 | logging.info(f"Starting {script_name} script...") 116 | 117 | try: 118 | main( 119 | app_config_path=args.app_config, 120 | m4b_config_path=args.m4b_config, 121 | user_config_path=args.user_config, 122 | ) 123 | logging.info(f"{script_name} script completed successfully") 124 | except FileNotFoundError as e: 125 | logging.error(f"File not found: {str(e)}") 126 | raise 127 | except json.JSONDecodeError as e: 128 | logging.error(f"Error decoding JSON: {str(e)}") 129 | raise 130 | except Exception as e: 131 | logging.error(f"An unexpected error occurred: {str(e)}") 132 | raise 133 | -------------------------------------------------------------------------------- /tests/test_fn_show_links.py: -------------------------------------------------------------------------------- 1 | import re 2 | import unittest 3 | from unittest.mock import patch 4 | 5 | from utils.fn_show_links import fn_show_links 6 | 7 | 8 | class TestFnShowLinks(unittest.TestCase): 9 | @patch("utils.fn_show_links.print") 10 | @patch("utils.fn_show_links.cls") 11 | @patch("utils.fn_show_links.input", create=True) 12 | def test_fn_show_links(self, mock_input, mock_cls, mock_print): 13 | """ 14 | Test fn_show_links function. 15 | """ 16 | # Mock input to avoid waiting for user input 17 | mock_input.return_value = "" 18 | 19 | # Prepare app_config sample data 20 | app_config = { 21 | "apps": [ 22 | { 23 | "name": "EARNAPP", 24 | "dashboard": "https://earnapp.com/dashboard", 25 | "link": "https://earnapp.com/i/3zulx7k", 26 | "image": "fazalfarhan01/earnapp", 27 | "flags": {"--uuid": {"length": 32}}, 28 | "claimURLBase": "To claim your node, after starting it, go to the app's dashboard and then visit the following link: https://earnapp.com/r/sdk-node-", 29 | }, 30 | { 31 | "name": "HONEYGAIN", 32 | "dashboard": "https://dashboard.honeygain.com/", 33 | "link": "https://r.honeygain.me/MINDL15721", 34 | "image": "honeygain/honeygain", 35 | "flags": {"--email": {}, "--password": {}}, 36 | }, 37 | { 38 | "name": "GRASS", 39 | "dashboard": "https://app.getgrass.io/dashboard", 40 | "link": "https://app.getgrass.io/register/?referralCode=qyvJmxgNUhcLo2f", 41 | "image": "mrcolorrain/grass", 42 | "flags": {"--email": {}, "--password": {}}, 43 | }, 44 | ], 45 | "extra-apps": [ 46 | { 47 | "name": "MYSTNODE", 48 | "dashboard": "https://mystnodes.com/nodes", 49 | "link": "https://mystnodes.co/?referral_code=Tc7RaS7Fm12K3Xun6mlU9q9hbnjojjl9aRBW8ZA9", 50 | "image": "mysteriumnetwork/myst", 51 | "flags": { 52 | "--manual": { 53 | "instructions": "Log into your device's mystnode local webdashboard, navigate to the Myst Node page and follow the onscreen instruction to complete the setup.\n\nDisclaimer: If you want to further optimize UPnP and port forwarding, consider setting manually 'network: host' for mystnode in your Docker compose. This may improve mystnode performance, but do this only if you know what you are doing and you are aware of potential security implications." 54 | } 55 | }, 56 | } 57 | ], 58 | "removed-apps": [], 59 | } 60 | 61 | # Call the function 62 | fn_show_links(app_config) 63 | 64 | # Collect printed lines from mock_print 65 | printed_lines = [call.args[0] for call in mock_print.call_args_list] 66 | printed_output = "\n".join(printed_lines) 67 | 68 | # Remove ANSI escape codes from printed output 69 | ansi_escape = re.compile(r"\x1b\[([0-9;]*m)") 70 | clean_printed_output = ansi_escape.sub("", printed_output) 71 | 72 | # Assertions to check if the output contains the expected strings 73 | self.assertIn("---APPS---", clean_printed_output) 74 | self.assertIn("EARNAPP: https://earnapp.com/i/3zulx7k", clean_printed_output) 75 | self.assertIn( 76 | "HONEYGAIN: https://r.honeygain.me/MINDL15721", clean_printed_output 77 | ) 78 | self.assertIn( 79 | "GRASS: https://app.getgrass.io/register/?referralCode=qyvJmxgNUhcLo2f", 80 | clean_printed_output, 81 | ) 82 | self.assertIn("---EXTRA-APPS---", clean_printed_output) 83 | self.assertIn( 84 | "MYSTNODE: https://mystnodes.co/?referral_code=Tc7RaS7Fm12K3Xun6mlU9q9hbnjojjl9aRBW8ZA9", 85 | clean_printed_output, 86 | ) 87 | 88 | @patch("utils.fn_show_links.logging.error") 89 | def test_fn_show_links_exception(self, mock_logging_error): 90 | """ 91 | Test fn_show_links function exception handling. 92 | """ 93 | # Prepare app_config sample data that will raise an exception 94 | app_config = None 95 | 96 | with self.assertRaises(Exception): 97 | fn_show_links(app_config) 98 | 99 | mock_logging_error.assert_called_once_with( 100 | "An error occurred in fn_show_links: 'NoneType' object has no attribute 'items'" 101 | ) 102 | 103 | 104 | if __name__ == "__main__": 105 | unittest.main() 106 | -------------------------------------------------------------------------------- /examples/fn_generic.py.template: -------------------------------------------------------------------------------- 1 | import os 2 | import argparse 3 | import logging 4 | import json 5 | from typing import Dict 6 | from colorama import Fore, Back, Style, just_fix_windows_console 7 | # Ensure the parent directory is in the sys.path 8 | import sys 9 | script_dir = os.path.dirname(os.path.abspath(__file__)) 10 | parent_dir = os.path.dirname(script_dir) 11 | if parent_dir not in sys.path: 12 | sys.path.append(parent_dir) 13 | # Import the module from the parent directory 14 | from utils import load, detect 15 | from utils.cls import cls 16 | 17 | def fn_example_function(app_config: Dict) -> None: 18 | """ 19 | Example function that uses all or a subset of all the configurations. 20 | 21 | Arguments: 22 | app_config -- the app config dictionary 23 | """ 24 | try: 25 | logging.info("Showing links of the apps") 26 | cls() 27 | just_fix_windows_console() 28 | # SPECIFIC FUNCTION LOGIC GOES HERE 29 | except Exception as e: 30 | logging.error(f"An error occurred in fn_example_function: {str(e)}") 31 | raise 32 | 33 | def main(app_config: Dict = None, m4b_config: Dict = None, user_config: Dict = None) -> None: 34 | """ 35 | Main function to call the example function. 36 | 37 | Arguments: 38 | app_config -- the app config dictionary 39 | m4b_config -- the m4b config dictionary 40 | user_config -- the user config dictionary 41 | """ 42 | fn_example_function(app_config) 43 | 44 | if __name__ == '__main__': 45 | # Get the script absolute path and name 46 | script_dir = os.path.dirname(os.path.abspath(__file__)) 47 | script_name = os.path.basename(__file__) 48 | 49 | # Parse command-line arguments 50 | parser = argparse.ArgumentParser(description='Run the module standalone.') 51 | # SET AS REQUIRED ALL THE CONFIGURATIONS THAT ARE REQUIRED BY THE MODULE IN THIS EXAMPLE WE SET app-config TRUE 52 | parser.add_argument('--app-config', type=str, required=False, help='Path to app_config JSON file') 53 | parser.add_argument('--m4b-config', type=str, required=False, help='Path to m4b_config JSON file') 54 | parser.add_argument('--user-config', type=str, required=False, help='Path to user_config JSON file') 55 | parser.add_argument('--log-dir', default=os.path.join(script_dir, 'logs'), help='Set the logging directory') 56 | parser.add_argument('--log-file', default='fn_example_function.log', help='Set the logging file name') 57 | parser.add_argument('--log-level', choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], default='INFO', help='Set the logging level') 58 | args = parser.parse_args() 59 | 60 | # Set logging level based on command-line arguments 61 | log_level = getattr(logging, args.log_level.upper(), None) 62 | if not isinstance(log_level, int): 63 | raise ValueError(f'Invalid log level: {args.log_level}') 64 | 65 | # Start logging 66 | os.makedirs(args.log_dir, exist_ok=True) 67 | logging.basicConfig( 68 | filename=os.path.join(args.log_dir, args.log_file), 69 | format='%(asctime)s - [%(levelname)s] - %(message)s', 70 | datefmt='%Y-%m-%d %H:%M:%S', 71 | level=log_level 72 | ) 73 | 74 | logging.info(f"Starting {script_name} script...") 75 | 76 | try: 77 | # Load the app_config JSON file 78 | app_config = {} 79 | if args.app_config: 80 | logging.debug("Loading app_config JSON file") 81 | with open(args.app_config, 'r') as f: 82 | app_config = json.load(f) 83 | logging.info("app_config JSON file loaded successfully") 84 | 85 | # Load the m4b_config JSON file if provided 86 | m4b_config = {} 87 | if args.m4b_config: 88 | logging.debug("Loading m4b_config JSON file") 89 | with open(args.m4b_config, 'r') as f: 90 | m4b_config = json.load(f) 91 | logging.info("m4b_config JSON file loaded successfully") 92 | else: 93 | logging.info("No m4b_config JSON file provided, proceeding without it") 94 | 95 | # Load the user_config JSON file if provided 96 | user_config = {} 97 | if args.user_config: 98 | logging.debug("Loading user_config JSON file") 99 | with open(args.user_config, 'r') as f: 100 | user_config = json.load(f) 101 | logging.info("user_config JSON file loaded successfully") 102 | else: 103 | logging.info("No user_config JSON file provided, proceeding without it") 104 | 105 | # Call the main function 106 | main(app_config=app_config, m4b_config=m4b_config, user_config=user_config) 107 | 108 | logging.info(f"{script_name} script completed successfully") 109 | except FileNotFoundError as e: 110 | logging.error(f"File not found: {str(e)}") 111 | raise 112 | except json.JSONDecodeError as e: 113 | logging.error(f"Error decoding JSON: {str(e)}") 114 | raise 115 | except Exception as e: 116 | logging.error(f"An unexpected error occurred: {str(e)}") 117 | raise 118 | -------------------------------------------------------------------------------- /utils/fn_bye.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import json 3 | import logging 4 | import os 5 | import random 6 | import sys 7 | import time 8 | from typing import Any 9 | 10 | from colorama import Fore, Style, just_fix_windows_console 11 | 12 | from utils.cls import cls 13 | from utils.loader import load_json_config 14 | 15 | # Ensure the parent directory is in the sys.path 16 | script_dir = os.path.dirname(os.path.abspath(__file__)) 17 | parent_dir = os.path.dirname(script_dir) 18 | if parent_dir not in sys.path: 19 | sys.path.append(parent_dir) 20 | 21 | # Import the module from the parent directory 22 | 23 | # Initialize colorama for Windows compatibility 24 | just_fix_windows_console() 25 | 26 | 27 | def fn_bye(m4b_config: dict[str, Any]) -> None: 28 | """ 29 | Quit the application gracefully with a farewell message. 30 | 31 | Arguments: 32 | m4b_config -- the m4b configuration dictionary 33 | """ 34 | try: 35 | logging.info("Exiting the application gracefully") 36 | cls() 37 | print( 38 | Fore.GREEN 39 | + "Thank you for using M4B! Please share this app with your friends!" 40 | + Style.RESET_ALL 41 | ) 42 | print(Fore.GREEN + "Exiting the application..." + Style.RESET_ALL) 43 | 44 | sleep_time = m4b_config.get("system", {}).get("sleep_time", 1) 45 | farewell_messages = m4b_config.get( 46 | "farewell_messages", 47 | [ 48 | "Have a fantastic day!", 49 | "Happy earning!", 50 | "Goodbye!", 51 | "Bye! Bye!", 52 | "Did you know \n if you simply click enter while setting up apps the app will be skipped ^^", 53 | "Did you know typing 404 while setting up apps the rest of the setup process will be skipped", 54 | ], 55 | ) 56 | print(random.choice(farewell_messages)) 57 | time.sleep(sleep_time) 58 | sys.exit(0) 59 | except Exception as e: 60 | logging.error(f"An error occurred in fn_bye: {str(e)}") 61 | raise 62 | 63 | 64 | def main(app_config_path: str, m4b_config_path: str, user_config_path: str) -> None: 65 | """ 66 | Main function to call the fn_bye function. 67 | 68 | Arguments: 69 | app_config_path -- the path to the app configuration file 70 | m4b_config_path -- the path to the m4b configuration file 71 | user_config_path -- the path to the user configuration file 72 | """ 73 | m4b_config = load_json_config(m4b_config_path) 74 | fn_bye(m4b_config) 75 | 76 | 77 | if __name__ == "__main__": 78 | # Get the script absolute path and name 79 | script_dir = os.path.dirname(os.path.abspath(__file__)) 80 | script_name = os.path.basename(__file__) 81 | 82 | # Parse command-line arguments 83 | parser = argparse.ArgumentParser(description="Run the module standalone.") 84 | parser.add_argument( 85 | "--app-config", type=str, required=False, help="Path to app_config JSON file" 86 | ) 87 | parser.add_argument( 88 | "--m4b-config", type=str, required=True, help="Path to m4b_config JSON file" 89 | ) 90 | parser.add_argument( 91 | "--user-config", type=str, required=False, help="Path to user_config JSON file" 92 | ) 93 | parser.add_argument( 94 | "--log-dir", 95 | default=os.path.join(script_dir, "logs"), 96 | help="Set the logging directory", 97 | ) 98 | parser.add_argument( 99 | "--log-file", default=f"{script_name}.log", help="Set the logging file name" 100 | ) 101 | parser.add_argument( 102 | "--log-level", 103 | choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], 104 | default="INFO", 105 | help="Set the logging level", 106 | ) 107 | args = parser.parse_args() 108 | 109 | # Set logging level based on command-line arguments 110 | log_level = getattr(logging, args.log_level.upper(), None) 111 | if not isinstance(log_level, int): 112 | raise ValueError(f"Invalid log level: {args.log_level}") 113 | 114 | # Start logging 115 | os.makedirs(args.log_dir, exist_ok=True) 116 | logging.basicConfig( 117 | filename=os.path.join(args.log_dir, args.log_file), 118 | format="%(asctime)s - [%(levelname)s] - %(message)s", 119 | datefmt="%Y-%m-%d %H:%M:%S", 120 | level=log_level, 121 | ) 122 | 123 | logging.info(f"Starting {script_name} script...") 124 | 125 | try: 126 | # Call the main function 127 | main( 128 | app_config_path=args.app_config, 129 | m4b_config_path=args.m4b_config, 130 | user_config_path=args.user_config, 131 | ) 132 | logging.info(f"{script_name} script completed successfully") 133 | except FileNotFoundError as e: 134 | logging.error(f"File not found: {str(e)}") 135 | raise 136 | except json.JSONDecodeError as e: 137 | logging.error(f"Error decoding JSON: {str(e)}") 138 | raise 139 | except Exception as e: 140 | logging.error(f"An unexpected error occurred: {str(e)}") 141 | raise 142 | -------------------------------------------------------------------------------- /utils/fn_reset_config.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import json 3 | import logging 4 | import os 5 | import shutil 6 | 7 | 8 | def reset_config(src_path: str, dest_path: str) -> None: 9 | """ 10 | Resets a configuration file by copying from the source path to the destination path. 11 | 12 | Arguments: 13 | src_path -- the source file path 14 | dest_path -- the destination file path 15 | """ 16 | try: 17 | # Configure logging 18 | logger = logging.getLogger(__name__) 19 | logger.info( 20 | f"Starting the process of resetting {os.path.basename(dest_path)}..." 21 | ) 22 | 23 | # Check if source file exists 24 | if not os.path.exists(src_path): 25 | raise FileNotFoundError(f"Source file {src_path} does not exist.") 26 | 27 | # Create destination directory if it does not exist 28 | dest_dir = os.path.dirname(dest_path) 29 | os.makedirs(dest_dir, exist_ok=True) 30 | 31 | # Copy the file 32 | shutil.copyfile(src_path, dest_path) 33 | logger.info(f"Successfully copied {src_path} to {dest_path}.") 34 | 35 | except FileNotFoundError as e: 36 | logger.error(f"File not found: {str(e)}") 37 | raise 38 | except PermissionError as e: 39 | logger.error(f"Permission denied: {str(e)}") 40 | raise 41 | except Exception as e: 42 | logger.error(f"An unexpected error occurred: {str(e)}") 43 | raise 44 | 45 | 46 | def main( 47 | app_config_path: str, 48 | m4b_config_path: str, 49 | user_config_path: str, 50 | src_path: str = "./template/user-config.json", 51 | dest_path: str = "./config/user-config.json", 52 | ) -> None: 53 | """ 54 | Main function to call the reset_config function. 55 | 56 | Arguments: 57 | app_config_path -- the path to the app configuration file 58 | m4b_config_path -- the path to the m4b configuration file 59 | user_config_path -- the path to the user configuration file 60 | src_path -- the source file path 61 | dest_path -- the destination file path 62 | """ 63 | reset_config(src_path=src_path, dest_path=dest_path) 64 | 65 | 66 | if __name__ == "__main__": 67 | # Get the script absolute path and name 68 | script_dir = os.path.dirname(os.path.abspath(__file__)) 69 | script_name = os.path.basename(__file__) 70 | 71 | # Parse command-line arguments 72 | parser = argparse.ArgumentParser(description="Run the module standalone.") 73 | parser.add_argument( 74 | "--app-config", type=str, required=False, help="Path to app_config JSON file" 75 | ) 76 | parser.add_argument( 77 | "--m4b-config", type=str, required=False, help="Path to m4b_config JSON file" 78 | ) 79 | parser.add_argument( 80 | "--user-config", type=str, required=False, help="Path to user_config JSON file" 81 | ) 82 | parser.add_argument( 83 | "--log-dir", 84 | default=os.path.join(script_dir, "logs"), 85 | help="Set the logging directory", 86 | ) 87 | parser.add_argument( 88 | "--log-file", default=f"{script_name}.log", help="Set the logging file name" 89 | ) 90 | parser.add_argument( 91 | "--log-level", 92 | choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], 93 | default="INFO", 94 | help="Set the logging level", 95 | ) 96 | parser.add_argument( 97 | "--src-path", 98 | type=str, 99 | default="./template/user-config.json", 100 | help="Set the source file path", 101 | ) 102 | parser.add_argument( 103 | "--dest-path", 104 | type=str, 105 | default="./config/user-config.json", 106 | help="Set the destination file path", 107 | ) 108 | args = parser.parse_args() 109 | 110 | # Set logging level based on command-line arguments 111 | log_level = getattr(logging, args.log_level.upper(), None) 112 | if not isinstance(log_level, int): 113 | raise ValueError(f"Invalid log level: {args.log_level}") 114 | 115 | # Start logging 116 | os.makedirs(args.log_dir, exist_ok=True) 117 | logging.basicConfig( 118 | filename=os.path.join(args.log_dir, args.log_file), 119 | format="%(asctime)s - [%(levelname)s] - %(message)s", 120 | datefmt="%Y-%m-%d %H:%M:%S", 121 | level=log_level, 122 | ) 123 | 124 | logging.info(f"Starting {script_name} script...") 125 | 126 | try: 127 | main( 128 | app_config_path=args.app_config, 129 | m4b_config_path=args.m4b_config, 130 | user_config_path=args.user_config, 131 | src_path=args.src_path, 132 | dest_path=args.dest_path, 133 | ) 134 | logging.info(f"{script_name} script completed successfully") 135 | except FileNotFoundError as e: 136 | logging.error(f"File not found: {str(e)}") 137 | raise 138 | except json.JSONDecodeError as e: 139 | logging.error(f"Error decoding JSON: {str(e)}") 140 | raise 141 | except Exception as e: 142 | logging.error(f"An unexpected error occurred: {str(e)}") 143 | raise 144 | -------------------------------------------------------------------------------- /config/m4b-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "project": { 3 | "project_version": "4.11.1", 4 | "compose_project_name": "money4band", 5 | "ds_project_server_url": "https://discord.com/invite/Fq8eeazBAD" 6 | }, 7 | "ports": { 8 | "default_port_base": 50000, 9 | "port_offset_per_app": 1000, 10 | "port_offset_per_instance": 3 11 | }, 12 | "system": { 13 | "sleep_time": 3, 14 | "arch_map": { 15 | "x86_64": "amd64", 16 | "amd64": "amd64", 17 | "aarch64": "arm64", 18 | "arm64": "arm64" 19 | }, 20 | "os_map": { 21 | "win32nt": "Windows", 22 | "windows_nt": "Windows", 23 | "windows": "Windows", 24 | "linux": "Linux", 25 | "darwin": "MacOS", 26 | "macos": "MacOS", 27 | "macosx": "MacOS", 28 | "mac": "MacOS", 29 | "osx": "MacOS", 30 | "cygwin": "Cygwin", 31 | "mingw": "MinGw", 32 | "msys": "Msys", 33 | "freebsd": "FreeBSD" 34 | }, 35 | "default_docker_platform": "linux/amd64" 36 | }, 37 | "menu": [ 38 | { 39 | "label": "Show supported apps' links", 40 | "function": "fn_show_links" 41 | }, 42 | { 43 | "label": "Install Docker", 44 | "function": "fn_install_docker" 45 | }, 46 | { 47 | "label": "Setup Apps", 48 | "function": "fn_setupApps" 49 | }, 50 | { 51 | "label": "Start apps stack", 52 | "function": "fn_startStack" 53 | }, 54 | { 55 | "label": "Stop apps stack", 56 | "function": "fn_stopStack" 57 | }, 58 | { 59 | "label": "Reset user-config File", 60 | "function": "fn_reset_config" 61 | }, 62 | { 63 | "label": "Multiproxy Tools", 64 | "function": "fn_multiproxy_tools" 65 | }, 66 | { 67 | "label": "Quit", 68 | "function": "fn_bye" 69 | } 70 | ], 71 | "farewell_messages": [ 72 | "Have a fantastic day!", 73 | "Enjoy the rest of your day!", 74 | "May your journey be filled with joy and success!", 75 | "Happy earning!", 76 | "Goodbye!", 77 | "See you soon!", 78 | "Take care and have a nice day!", 79 | "Bye! Bye!", 80 | "Until we meet again!" 81 | ], 82 | "files": { 83 | "env_filename": ".env", 84 | "dkcom_filename": "docker-compose.yaml" 85 | }, 86 | "word_lists": { 87 | "adjectives": [ 88 | "Swift", 89 | "Silent", 90 | "Brave", 91 | "Cunning", 92 | "Mighty", 93 | "Fierce", 94 | "Bold", 95 | "Nimble", 96 | "Stealthy", 97 | "Valiant", 98 | "Gallant", 99 | "Fearless", 100 | "Eager", 101 | "Keen", 102 | "Lively", 103 | "Daring", 104 | "Energetic", 105 | "Dynamic", 106 | "Vigorous", 107 | "Intrepid", 108 | "Stalwart", 109 | "Resolute", 110 | "Tenacious", 111 | "Dauntless", 112 | "Radiant", 113 | "Blazing", 114 | "Roaring", 115 | "Thunderous", 116 | "Mystic", 117 | "Majestic", 118 | "Serene", 119 | "Glorious", 120 | "Splendid", 121 | "Miraculous", 122 | "Enchanted", 123 | "Magnificent", 124 | "Invincible", 125 | "Formidable", 126 | "Epic", 127 | "Legendary", 128 | "Mythic", 129 | "Supreme", 130 | "Ultimate", 131 | "Immortal", 132 | "Divine", 133 | "Spectacular", 134 | "Amazing", 135 | "Wondrous" 136 | ], 137 | "animals": [ 138 | "Panther", 139 | "Tiger", 140 | "Eagle", 141 | "Falcon", 142 | "Lion", 143 | "Wolf", 144 | "Leopard", 145 | "Hawk", 146 | "Dragon", 147 | "Phoenix", 148 | "Cheetah", 149 | "Jaguar", 150 | "Cougar", 151 | "Raptor", 152 | "Griffin", 153 | "Orca", 154 | "Shark", 155 | "Dolphin", 156 | "Whale", 157 | "Bear", 158 | "Cobra", 159 | "Scorpion", 160 | "Python", 161 | "Lynx", 162 | "Mustang", 163 | "Viper", 164 | "Bison", 165 | "Raven", 166 | "Owl", 167 | "Falcon", 168 | "Lynx", 169 | "Puma", 170 | "Gazelle", 171 | "Mongoose", 172 | "Jackal", 173 | "Panther", 174 | "Kangaroo", 175 | "Otter", 176 | "Hedgehog", 177 | "Fox", 178 | "Eagle", 179 | "Badger", 180 | "Squirrel", 181 | "Porcupine", 182 | "Beaver", 183 | "Wombat", 184 | "Koala", 185 | "Panda", 186 | "Tiger", 187 | "Buffalo", 188 | "Mammoth", 189 | "Walrus", 190 | "Seal", 191 | "Penguin", 192 | "Parrot", 193 | "Flamingo", 194 | "Pelican", 195 | "Stork", 196 | "Swan", 197 | "Peacock", 198 | "Crane", 199 | "Heron", 200 | "Ibis", 201 | "Kiwi", 202 | "Toucan", 203 | "Macaw", 204 | "Chameleon", 205 | "Iguana", 206 | "Gecko", 207 | "Komodo", 208 | "Monitor", 209 | "Anaconda", 210 | "Boa", 211 | "Crocodile", 212 | "Alligator", 213 | "Turtle", 214 | "Tortoise" 215 | ] 216 | }, 217 | "network": { 218 | "driver": "bridge", 219 | "subnet": "172.19.7.0", 220 | "netmask": "27" 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /utils/detector.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import platform 4 | 5 | # Ensure the parent directory is in the sys.path 6 | import sys 7 | from typing import Any 8 | 9 | import psutil 10 | 11 | from utils.dumper import write_json 12 | from utils.loader import load_json_config 13 | 14 | script_dir = os.path.dirname(os.path.abspath(__file__)) 15 | parent_dir = os.path.dirname(script_dir) 16 | if parent_dir not in sys.path: 17 | sys.path.append(parent_dir) 18 | 19 | # Import the module from the parent directory 20 | 21 | 22 | def detect_os(m4b_config_path_or_dict: Any) -> dict[str, str]: 23 | """ 24 | Detect the operating system based on the system's platform and map it according to the m4b configuration. 25 | 26 | Args: 27 | m4b_config_path_or_dict (Any): The path to the m4b config file or the config dictionary. 28 | 29 | Returns: 30 | Dict[str, str]: A dictionary containing the detected OS type. 31 | 32 | Raises: 33 | Exception: If an error occurs during OS detection or if the OS is not recognized. 34 | """ 35 | try: 36 | m4b_config = load_json_config(m4b_config_path_or_dict) 37 | 38 | logging.debug("Detecting OS type") 39 | os_map = m4b_config.get("system", {}).get("os_map", {}) 40 | 41 | # Get the OS type from the platform module and convert it to lowercase 42 | detected_os = platform.system().lower() 43 | logging.info(f"Detected OS: {detected_os}") 44 | 45 | # Map the detected OS using the os_map from the config 46 | mapped_os = os_map.get(detected_os, "unknown") 47 | 48 | if mapped_os == "unknown": 49 | raise ValueError( 50 | f"OS type '{detected_os}' is not recognized in the provided os_map." 51 | ) 52 | 53 | logging.info(f"Mapped OS: {mapped_os}") 54 | return {"os_type": mapped_os} 55 | 56 | except KeyError as e: 57 | logging.error(f"KeyError in configuration: {str(e)}") 58 | raise 59 | except Exception as e: 60 | logging.error(f"An error occurred while detecting OS: {str(e)}") 61 | raise 62 | 63 | 64 | def detect_architecture(m4b_config_path_or_dict: Any) -> dict[str, str]: 65 | """ 66 | Detect the system architecture and return its type. 67 | 68 | Args: 69 | m4b_config_path_or_dict (Any): The path to the m4b config file or the config dictionary. 70 | 71 | Returns: 72 | Dict[str, str]: A dictionary containing the detected architecture and Docker architecture. 73 | 74 | Raises: 75 | Exception: If an error occurs during architecture detection. 76 | """ 77 | try: 78 | m4b_config = load_json_config(m4b_config_path_or_dict) 79 | 80 | logging.debug("Detecting system architecture") 81 | arch_map = m4b_config.get("system", {}).get("arch_map", {}) 82 | arch = platform.machine().lower() 83 | dkarch = arch_map.get(arch, "unknown") 84 | logging.info( 85 | f"System architecture detected: {arch}, Docker architecture has been set to {dkarch}" 86 | ) 87 | return {"arch": arch, "dkarch": dkarch} 88 | except Exception as e: 89 | logging.error(f"An error occurred while detecting architecture: {str(e)}") 90 | raise 91 | 92 | 93 | def get_system_memory_and_cores(): 94 | logging.debug("Retrieving system memory and cores") 95 | total_memory = psutil.virtual_memory().total / (1024**2) 96 | cores = psutil.cpu_count(logical=False) 97 | logging.debug(f"Total RAM: {total_memory:.2f} MB, CPU cores: {cores}") 98 | return total_memory, cores 99 | 100 | 101 | def calculate_resource_limits(user_config_path_or_dict: Any) -> None: 102 | logging.debug("Determining resource limits") 103 | user_config = load_json_config(user_config_path_or_dict) 104 | total_memory, cores = get_system_memory_and_cores() 105 | memory_cap = user_config.get("resource_limits", {}).get("ram_cap_mb_default") 106 | if memory_cap > total_memory: 107 | logging.debug( 108 | f"Memory cap {memory_cap} MB is greater than total system memory {total_memory} MB. Using total memory as cap." 109 | ) 110 | memory_cap = total_memory 111 | 112 | resource_limits = {} 113 | resource_limits["app_mem_reserv_little"] = f"{int(max(memory_cap * 0.2, 64))}m" 114 | resource_limits["app_mem_limit_little"] = f"{int(max(memory_cap * 0.4, 128))}m" 115 | resource_limits["app_mem_reserv_medium"] = f"{int(max(memory_cap * 0.4, 128))}m" 116 | resource_limits["app_mem_limit_medium"] = f"{int(max(memory_cap * 0.6, 256))}m" 117 | resource_limits["app_mem_reserv_big"] = f"{int(max(memory_cap * 0.6, 256))}m" 118 | resource_limits["app_mem_limit_big"] = f"{int(max(memory_cap * 0.8, 512))}m" 119 | resource_limits["app_mem_reserv_huge"] = f"{int(max(memory_cap * 0.8, 512))}m" 120 | resource_limits["app_mem_limit_huge"] = f"{int(max(memory_cap, 1024))}m" 121 | 122 | resource_limits["app_cpu_limit_little"] = round(max(cores * 0.2, 0.8), 1) 123 | resource_limits["app_cpu_limit_medium"] = round(max(cores * 0.4, 1.0), 1) 124 | resource_limits["app_cpu_limit_big"] = round(max(cores * 0.6, 1.0), 1) 125 | resource_limits["app_cpu_limit_huge"] = round(max(cores * 0.8, 1.0), 1) 126 | 127 | user_config.get("resource_limits", {}).update(resource_limits) 128 | write_json(user_config, user_config_path_or_dict) 129 | logging.debug("Resource limits updated") 130 | -------------------------------------------------------------------------------- /.resources/.www/Home.css: -------------------------------------------------------------------------------- 1 | .u-section-1 { 2 | background-image: url(".images/default-image.jpg"); 3 | } 4 | 5 | .u-section-1 .u-sheet-1 { 6 | min-height: 592px; 7 | } 8 | 9 | .u-section-1 .u-tabs-1 { 10 | min-height: 472px; 11 | margin: 60px auto 60px 0; 12 | } 13 | 14 | .u-section-1 .u-tab-link-1 { 15 | font-size: 1.25rem; 16 | background-image: none; 17 | font-weight: 600; 18 | padding: 10px 25px; 19 | } 20 | 21 | .u-section-1 .u-tab-link-2 { 22 | font-size: 1.25rem; 23 | background-image: none; 24 | font-weight: 600; 25 | padding: 10px 25px; 26 | } 27 | 28 | .u-section-1 .u-tab-link-3 { 29 | font-size: 1.25rem; 30 | background-image: none; 31 | font-weight: 600; 32 | padding: 10px 25px; 33 | } 34 | 35 | .u-section-1 .u-tab-pane-1 { 36 | background-image: none; 37 | } 38 | 39 | .u-section-1 .u-container-layout-1 { 40 | padding: 30px; 41 | } 42 | 43 | .u-section-1 .u-text-1 { 44 | font-weight: 700; 45 | margin: 0 auto 0 0; 46 | } 47 | 48 | .u-section-1 .u-text-2 { 49 | margin: 21px auto 0 0; 50 | } 51 | 52 | .u-section-1 .u-table-1 { 53 | width: 806px; 54 | margin: 20px auto 0 0; 55 | } 56 | 57 | .u-section-1 .u-table-cell-1 { 58 | text-transform: uppercase; 59 | font-weight: 700; 60 | } 61 | 62 | .u-section-1 .u-btn-1 { 63 | background-image: none; 64 | padding: 0; 65 | } 66 | 67 | .u-section-1 .u-btn-2 { 68 | background-image: none; 69 | padding: 0; 70 | } 71 | 72 | .u-section-1 .u-table-cell-4 { 73 | text-transform: uppercase; 74 | font-weight: 700; 75 | } 76 | 77 | .u-section-1 .u-btn-3 { 78 | background-image: none; 79 | padding: 0; 80 | } 81 | 82 | .u-section-1 .u-btn-4 { 83 | background-image: none; 84 | padding: 0; 85 | } 86 | 87 | .u-section-1 .u-table-cell-7 { 88 | text-transform: uppercase; 89 | font-weight: 700; 90 | } 91 | 92 | .u-section-1 .u-btn-5 { 93 | background-image: none; 94 | padding: 0; 95 | } 96 | 97 | .u-section-1 .u-btn-6 { 98 | background-image: none; 99 | padding: 0; 100 | } 101 | 102 | .u-section-1 .u-table-cell-10 { 103 | text-transform: uppercase; 104 | font-weight: 700; 105 | } 106 | 107 | .u-section-1 .u-btn-7 { 108 | background-image: none; 109 | padding: 0; 110 | } 111 | 112 | .u-section-1 .u-btn-8 { 113 | background-image: none; 114 | padding: 0; 115 | } 116 | 117 | .u-section-1 .u-table-cell-13 { 118 | text-transform: uppercase; 119 | font-weight: 700; 120 | } 121 | 122 | .u-section-1 .u-btn-9 { 123 | background-image: none; 124 | padding: 0; 125 | } 126 | 127 | .u-section-1 .u-btn-10 { 128 | background-image: none; 129 | padding: 0; 130 | } 131 | 132 | .u-section-1 .u-table-cell-16 { 133 | text-transform: uppercase; 134 | font-weight: 700; 135 | } 136 | 137 | .u-section-1 .u-btn-11 { 138 | background-image: none; 139 | padding: 0; 140 | } 141 | 142 | .u-section-1 .u-btn-12 { 143 | background-image: none; 144 | padding: 0; 145 | } 146 | 147 | .u-section-1 .u-table-cell-19 { 148 | text-transform: uppercase; 149 | font-weight: 700; 150 | } 151 | 152 | .u-section-1 .u-btn-13 { 153 | background-image: none; 154 | padding: 0; 155 | } 156 | 157 | .u-section-1 .u-btn-14 { 158 | background-image: none; 159 | padding: 0; 160 | } 161 | 162 | .u-section-1 .u-tab-pane-2 { 163 | background-image: none; 164 | } 165 | 166 | .u-section-1 .u-container-layout-2 { 167 | padding: 30px; 168 | } 169 | 170 | .u-section-1 .u-text-3 { 171 | line-height: 1.8; 172 | margin: -30px -30px 0; 173 | } 174 | 175 | .u-section-1 .u-btn-15 { 176 | background-image: none; 177 | padding: 0; 178 | } 179 | 180 | .u-section-1 .u-tab-pane-3 { 181 | background-image: none; 182 | } 183 | 184 | .u-section-1 .u-container-layout-3 { 185 | padding: 30px; 186 | } 187 | 188 | .u-section-1 .u-text-4 { 189 | margin-top: 0; 190 | margin-bottom: 0; 191 | } 192 | 193 | .u-section-1 .u-text-5 { 194 | margin-left: 0; 195 | margin-bottom: 0; 196 | } 197 | 198 | @media (max-width: 1199px) { 199 | .u-section-1 .u-tabs-1 { 200 | height: auto; 201 | margin-right: initial; 202 | margin-left: initial; 203 | } 204 | 205 | .u-section-1 .u-text-1 { 206 | margin-top: 1px; 207 | } 208 | 209 | .u-section-1 .u-text-2 { 210 | width: auto; 211 | margin-top: 20px; 212 | margin-right: 314px; 213 | } 214 | 215 | .u-section-1 .u-table-1 { 216 | width: 576px; 217 | } 218 | 219 | .u-section-1 .u-text-3 { 220 | margin-left: 0; 221 | margin-right: 0; 222 | } 223 | } 224 | 225 | @media (max-width: 991px) { 226 | .u-section-1 .u-text-1 { 227 | margin-top: 0; 228 | } 229 | 230 | .u-section-1 .u-text-2 { 231 | margin-right: 94px; 232 | } 233 | 234 | .u-section-1 .u-table-1 { 235 | width: 457px; 236 | } 237 | } 238 | 239 | @media (max-width: 767px) { 240 | .u-section-1 .u-container-layout-1 { 241 | padding-left: 10px; 242 | padding-right: 10px; 243 | } 244 | 245 | .u-section-1 .u-text-2 { 246 | margin-right: 0; 247 | } 248 | 249 | .u-section-1 .u-table-1 { 250 | width: 317px; 251 | } 252 | 253 | .u-section-1 .u-container-layout-2 { 254 | padding-left: 10px; 255 | padding-right: 10px; 256 | } 257 | 258 | .u-section-1 .u-container-layout-3 { 259 | padding-left: 10px; 260 | padding-right: 10px; 261 | } 262 | } 263 | 264 | @media (max-width: 575px) { 265 | .u-section-1 .u-sheet-1 { 266 | min-height: 1083px; 267 | } 268 | 269 | .u-section-1 .u-tabs-1 { 270 | min-height: 963px; 271 | margin-right: initial; 272 | margin-left: initial; 273 | } 274 | } -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | mattiar11@duck.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /utils/checker.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import sys 4 | import time 5 | 6 | import requests 7 | 8 | from utils.helper import ensure_service 9 | 10 | # Ensure the parent directory is in the sys.path 11 | script_dir = os.path.dirname(os.path.abspath(__file__)) 12 | parent_dir = os.path.dirname(script_dir) 13 | if parent_dir not in sys.path: 14 | sys.path.append(parent_dir) 15 | 16 | # Store the Docker Hub base URL in a variable 17 | # This constant stores the base URL for the Docker Hub Registry API. 18 | # It is used to construct API requests for fetching image tags from Docker Hub. 19 | DOCKERHUB_BASE_URL = "https://registry.hub.docker.com/v2/" 20 | 21 | # Store the GitHub Container Registry base URL in a variable 22 | # This constant stores the base URL for the GitHub Container Registry (GHCR) API. 23 | # It is used to construct API requests for fetching image tags from GHCR. 24 | # Note: GHCR requires authentication (GitHub PAT) for tag compatibility checks. 25 | GHCR_BASE_URL = "https://ghcr.io/v2/" 26 | 27 | 28 | def fetch_docker_tags(image: str) -> dict | None: 29 | """ 30 | Fetch the tags of a Docker image from Docker Hub. 31 | 32 | Args: 33 | image (str): The name of the Docker image. 34 | 35 | Returns: 36 | Optional[Dict]: A dictionary containing tag information if successful, None otherwise. 37 | """ 38 | try: 39 | # Detect GHCR images (ghcr.io/owner/image) 40 | if image.startswith("ghcr.io/"): 41 | # Remove 'ghcr.io/' prefix for API 42 | ghcr_image = image.replace("ghcr.io/", "") 43 | url = f"{GHCR_BASE_URL}{ghcr_image}/tags/list" 44 | response = requests.get(url) 45 | response.raise_for_status() 46 | # GHCR returns tags in 'tags' key, but does not provide architecture info 47 | tags = response.json().get("tags", []) 48 | # Return a Docker Hub-like structure for compatibility 49 | return {"results": [{"name": tag, "images": []} for tag in tags]} 50 | else: 51 | # Docker Hub image (owner/image or library/image) 52 | url = f"{DOCKERHUB_BASE_URL}repositories/{image}/tags" 53 | response = requests.get(url) 54 | response.raise_for_status() 55 | return response.json() 56 | except requests.RequestException as e: 57 | logging.error(f"Error fetching Docker tags for {image}: {str(e)}") 58 | return None 59 | 60 | 61 | def check_img_arch_support(image: str, tag: str, docker_platform: str) -> bool: 62 | """ 63 | Check if a Docker image tag supports the given docker platform. 64 | 65 | Args: 66 | image (str): The name of the Docker image. 67 | tag (str): The specific tag of the Docker image. 68 | arch (str): The architecture to check for compatibility. 69 | 70 | Returns: 71 | bool: True if the architecture is supported, False otherwise. 72 | """ 73 | if image.startswith("ghcr.io/"): 74 | logging.warning( 75 | f"Skipping architecture/tag compatibility check for GHCR image: {image}. (As it would require a GH PAT). Using provided tag '{tag}' as compatible." 76 | ) 77 | print( 78 | f"\n[WARNING] Cannot check architecture/tag for GHCR image {image}. (As it would require a GH PAT). Using provided tag '{tag}'." 79 | ) 80 | time.sleep(4) 81 | return True 82 | arch = docker_platform.split("/")[1] 83 | tags_info = fetch_docker_tags(image) 84 | if tags_info is None: 85 | return False 86 | 87 | tag_info = next((t for t in tags_info.get("results", []) if t["name"] == tag), None) 88 | if not tag_info: 89 | logging.error(f"Tag {tag} not found for image {image}") 90 | return False 91 | 92 | return any(image_info["architecture"] == arch for image_info in tag_info["images"]) 93 | 94 | 95 | def get_compatible_tag(image: str, docker_platform: str) -> str | None: 96 | """ 97 | Get a compatible tag for the given architecture if the default tag is not supported. 98 | If no compatible tag is found, ensure multi-arch emulation support with binfmt. 99 | 100 | Args: 101 | image (str): The name of the Docker image. 102 | arch (str): The architecture to check for compatibility. 103 | 104 | Returns: 105 | Optional[str]: The compatible tag name if found, None otherwise. 106 | """ 107 | arch = docker_platform.split("/")[1] 108 | tags_info = fetch_docker_tags(image) 109 | if tags_info is None: 110 | return None 111 | 112 | compatible_tag = next( 113 | ( 114 | t["name"] 115 | for t in tags_info.get("results", []) 116 | if any(image_info["architecture"] == arch for image_info in t["images"]) 117 | ), 118 | None, 119 | ) 120 | 121 | if not compatible_tag: 122 | # Construct the path to the docker.binfmt.service file 123 | service_file_path = os.path.join( 124 | os.getcwd(), ".resources", ".files", "docker.binfmt.service" 125 | ) 126 | 127 | # Ensure multi-arch emulation support with binfmt if no compatible tag is found 128 | ensure_service( 129 | service_name="docker.binfmt", service_file_path=service_file_path 130 | ) 131 | 132 | # Log and inform the user that no compatible tag for the architecture was found 133 | logging.warning( 134 | f"No compatible tag found for {image} on platform {docker_platform}. The software will attempt to run the app using binfmt multi-arch emulation." 135 | ) 136 | else: 137 | logging.info( 138 | f"Found compatible tag {compatible_tag} for {image} on platform {docker_platform}" 139 | ) 140 | 141 | return compatible_tag 142 | -------------------------------------------------------------------------------- /utils/prompt_helper.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import re 4 | import sys 5 | 6 | from colorama import Fore, Style 7 | 8 | from utils.generator import validate_uuid 9 | 10 | # Ensure the parent directory is in the sys.path 11 | script_dir = os.path.dirname(os.path.abspath(__file__)) 12 | parent_dir = os.path.dirname(script_dir) 13 | if parent_dir not in sys.path: 14 | sys.path.append(parent_dir) 15 | 16 | 17 | def ask_question_yn(question: str, default: bool = False) -> bool: 18 | """ 19 | Ask a yes/no question and return the answer as a boolean. 20 | 21 | Args: 22 | question (str): The question to ask the user. 23 | default (bool, optional): The default response value if the user enters an empty string. Defaults to False. 24 | 25 | Returns: 26 | bool: True if the user answered yes, False if the user answered no. 27 | """ 28 | yes = {"yes", "y"} 29 | no = {"no", "n"} 30 | done = None 31 | 32 | while done is None: 33 | choice = ( 34 | input( 35 | f"{Fore.GREEN}{question} (y/n) (default: {Fore.YELLOW}{'yes' if default else 'no'}{Fore.GREEN}):{Style.RESET_ALL} " 36 | ) 37 | .lower() 38 | .strip() 39 | ) 40 | if not choice: 41 | done = default 42 | elif choice in yes: 43 | done = True 44 | elif choice in no: 45 | done = False 46 | else: 47 | print(f"{Fore.RED}Please respond with 'yes' or 'no'{Style.RESET_ALL}") 48 | logging.info(f"User response to '{question}': {choice}") 49 | return done 50 | 51 | 52 | def ask_string(prompt: str, default: str = "", show_default: bool = True) -> str: 53 | """ 54 | Ask the user for a string and return it. 55 | 56 | Args: 57 | prompt (str): The prompt to display to the user. 58 | default (str, optional): The default value if the user enters an empty string. Defaults to "". 59 | show_default (bool, optional): Whether to show the default value in the prompt. Defaults to True. 60 | 61 | Returns: 62 | str: The string entered by the user. 63 | """ 64 | prompt_text = f"{Fore.GREEN}{prompt}" 65 | if show_default: 66 | prompt_text += f" (default/current value: {Fore.YELLOW}{default}{Fore.GREEN})" 67 | prompt_text += f":{Style.RESET_ALL} " 68 | 69 | while True: 70 | response = input(prompt_text).strip() 71 | if not response: 72 | response = default 73 | if ( 74 | not response 75 | ): # As default is empty if not specified then we throw this error 76 | print(f"{Fore.RED}Input cannot be empty.{Style.RESET_ALL}") 77 | continue 78 | logging.debug(f"User response to '{prompt}': {response}") 79 | return response 80 | 81 | 82 | def ask_email(prompt: str, default: str = "", show_default: bool = True) -> str: 83 | """ 84 | Ask the user for an email address and validate it. 85 | 86 | Args: 87 | prompt (str): The prompt to display to the user. 88 | default (str, optional): The default value if the user enters an empty string. Defaults to "". 89 | show_default (bool, optional): Whether to show the default value in the prompt. Defaults to True. 90 | 91 | Returns: 92 | str: The validated email address. 93 | """ 94 | prompt_text = f"{Fore.GREEN}{prompt}" 95 | if show_default: 96 | prompt_text += f" (default/current value: {Fore.YELLOW}{default}{Fore.GREEN})" 97 | prompt_text += f":{Style.RESET_ALL} " 98 | 99 | while True: 100 | email = input(prompt_text).strip() 101 | if not email: 102 | email = default 103 | if not email: # As default is empty if not specified then we throw this error 104 | print(f"{Fore.RED}Email address cannot be empty.{Style.RESET_ALL}") 105 | continue 106 | elif not re.match(r"[^@]+@[^@]+\.[^@]+", email): 107 | print(f"{Fore.RED}Invalid email address.{Style.RESET_ALL}") 108 | continue 109 | else: 110 | logging.debug(f"User entered email: {email}") 111 | return email 112 | 113 | 114 | def ask_uuid( 115 | prompt: str, length: int, default: str = "", show_default: bool = True 116 | ) -> str: 117 | """ 118 | Ask the user for a UUID and validate it. 119 | 120 | Args: 121 | prompt (str): The prompt to display to the user. 122 | length (int): The expected length of the UUID. 123 | default (str, optional): The default value if the user enters an empty string. Defaults to "". 124 | show_default (bool, optional): Whether to show the default value in the prompt. Defaults to True. 125 | 126 | Returns: 127 | str: The validated UUID. 128 | """ 129 | prompt_text = f"{Fore.GREEN}{prompt}" 130 | if show_default: 131 | prompt_text += f" (default/current value: {Fore.YELLOW}{default}{Fore.GREEN})" 132 | prompt_text += f":{Style.RESET_ALL} " 133 | 134 | while True: 135 | uuid = input(prompt_text).lower().strip() 136 | if not uuid: 137 | uuid = default 138 | if not uuid: # As default is empty if not specified then we throw this error 139 | print(f"{Fore.RED}UUID cannot be empty.{Style.RESET_ALL}") 140 | continue 141 | elif len(uuid) != length: 142 | print(f"{Fore.RED}Invalid UUID length.{Style.RESET_ALL}") 143 | logging.warning( 144 | f"Invalid UUID length. Expected: {length}, Got: {len(uuid)}" 145 | ) 146 | elif not validate_uuid(uuid, length): 147 | print(f"{Fore.RED}Invalid UUID format.{Style.RESET_ALL}") 148 | else: 149 | logging.debug(f"User entered UUID: {uuid}") 150 | return uuid 151 | 152 | 153 | def main() -> None: 154 | """ 155 | Main function to run the prompt helper standalone. 156 | """ 157 | 158 | 159 | if __name__ == "__main__": 160 | main() 161 | -------------------------------------------------------------------------------- /utils/updater.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | import re 5 | import sys 6 | import urllib.request 7 | from datetime import datetime 8 | 9 | from colorama import Fore, Style, just_fix_windows_console 10 | 11 | from utils.loader import load_json_config 12 | 13 | script_dir = os.path.dirname(os.path.abspath(__file__)) 14 | parent_dir = os.path.dirname(script_dir) 15 | if parent_dir not in sys.path: 16 | sys.path.append(parent_dir) 17 | 18 | 19 | class Version: 20 | """ 21 | A class to represent a version number in the format major.minor.patch. 22 | Supports comparison operators (==, !=, <, >, <=, >=) and can be created from a string or individual components. 23 | """ 24 | 25 | version_regex = re.compile( 26 | r"(?:(?:v|version)?\s*)?(\d+)\.(\d+)\.(\d+)", re.IGNORECASE 27 | ) 28 | 29 | @staticmethod 30 | def from_string(version_str: str): 31 | """ 32 | Create a Version object by extracting version numbers from a string. 33 | """ 34 | version_str = version_str.strip() 35 | match = Version.version_regex.search(version_str) 36 | if not match: 37 | raise ValueError(f"Invalid version string format: '{version_str}'") 38 | major, minor, patch = match.groups() 39 | return Version(int(major), int(minor), int(patch)) 40 | 41 | def __init__(self, major: int, minor: int, patch: int): 42 | self.major = major 43 | self.minor = minor 44 | self.patch = patch 45 | 46 | def __str__(self): 47 | return f"{self.major}.{self.minor}.{self.patch}" 48 | 49 | def __repr__(self): 50 | return str(self) 51 | 52 | def __eq__(self, other): 53 | if not isinstance(other, Version): 54 | return False 55 | return ( 56 | self.major == other.major 57 | and self.minor == other.minor 58 | and self.patch == other.patch 59 | ) 60 | 61 | def __lt__(self, other): 62 | if not isinstance(other, Version): 63 | return NotImplemented 64 | if self.major < other.major: 65 | return True 66 | elif self.major > other.major: 67 | return False 68 | elif self.minor < other.minor: 69 | return True 70 | elif self.minor > other.minor: 71 | return False 72 | else: # minor is equal 73 | return self.patch < other.patch 74 | 75 | def __gt__(self, other): 76 | if not isinstance(other, Version): 77 | return NotImplemented 78 | return not self.__lt__(other) and not self.__eq__(other) 79 | 80 | def __le__(self, other): 81 | if not isinstance(other, Version): 82 | return NotImplemented 83 | return self.__lt__(other) or self.__eq__(other) 84 | 85 | def __ge__(self, other): 86 | if not isinstance(other, Version): 87 | return NotImplemented 88 | return not self.__lt__(other) 89 | 90 | def __ne__(self, other): 91 | return not self.__eq__(other) 92 | 93 | 94 | def get_latest_releases(count: int = 5) -> list[dict]: 95 | owner = "MRColorR" 96 | repo = "money4band" 97 | url = f"https://api.github.com/repos/{owner}/{repo}/releases" 98 | try: 99 | with urllib.request.urlopen(url) as response: 100 | data = response.read().decode() 101 | releases = json.loads(data) 102 | stripped_releases = [] 103 | for release in releases: 104 | if release["prerelease"]: 105 | continue 106 | if release["draft"]: 107 | continue 108 | name = release["name"] 109 | if not name: 110 | name = release["tag_name"] 111 | if not name: 112 | continue 113 | try: 114 | version = Version.from_string(name) 115 | except ValueError: 116 | logging.warning( 117 | f"Skipping release with unparseable version: '{name}'" 118 | ) 119 | continue 120 | url = release["html_url"] 121 | published_at = release["published_at"] 122 | published_at = datetime.strptime(published_at, "%Y-%m-%dT%H:%M:%SZ") 123 | stripped_releases.append( 124 | { 125 | "name": name, 126 | "version": version, 127 | "url": url, 128 | "published_at": published_at, 129 | } 130 | ) 131 | stripped_releases.sort(key=lambda x: x["version"], reverse=True) 132 | return stripped_releases[:count] 133 | except urllib.error.HTTPError as e: 134 | raise Exception(f"Failed to fetch releases. HTTP Error: {e.code}") 135 | except urllib.error.URLError as e: 136 | raise Exception(f"Failed to fetch releases. URL Error: {e.reason}") 137 | except json.JSONDecodeError: 138 | raise Exception("Failed to parse JSON response.") 139 | except Exception as e: 140 | raise Exception(f"An error occurred: {e}") 141 | 142 | 143 | def check_update_available(m4b_config_path_or_dict: str | dict) -> None: 144 | just_fix_windows_console() 145 | m4b_config = load_json_config(m4b_config_path_or_dict) 146 | try: 147 | current_version_str = m4b_config.get("project", {}).get( 148 | "project_version", "0.0.0" 149 | ) 150 | current_version = Version.from_string(current_version_str) 151 | latest_releases = get_latest_releases() 152 | if not latest_releases: 153 | print(f"{Fore.YELLOW}No releases found.") 154 | return 155 | latest_release = latest_releases[0] 156 | if current_version < latest_release["version"]: 157 | print( 158 | f"{Fore.YELLOW}New version available: {latest_release['version']}, published at {latest_release['published_at']}" 159 | ) 160 | print(f"Download URL: {Style.RESET_ALL}{latest_release['url']}") 161 | except Exception as e: 162 | logging.error(e) 163 | print(f"Error checking for updates: {e}") 164 | 165 | 166 | def main(): 167 | releases = get_latest_releases() 168 | for release in releases: 169 | print(release) 170 | 171 | 172 | if __name__ == "__main__": 173 | main() 174 | -------------------------------------------------------------------------------- /utils/loader.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import importlib.util 3 | import json 4 | import logging 5 | import os 6 | from typing import Any 7 | 8 | 9 | def load_json_config(config_path_or_dict: Any) -> dict[str, Any]: 10 | """ 11 | Load JSON config variables from a file or dictionary. 12 | 13 | Arguments: 14 | config_path_or_dict -- the config file path or dictionary 15 | 16 | Returns: 17 | Dict[str, Any] -- The loaded configuration dictionary. 18 | 19 | Raises: 20 | FileNotFoundError -- If the config file is not found. 21 | JSONDecodeError -- If there is an error decoding the JSON file. 22 | ValueError -- If the config type is invalid. 23 | """ 24 | if isinstance(config_path_or_dict, str): 25 | # If config is a string, assume it's a file path and load the JSON file 26 | try: 27 | with open(config_path_or_dict) as f: 28 | logging.debug(f"Loading config from file: {config_path_or_dict}") 29 | return json.load(f) 30 | except FileNotFoundError: 31 | logging.error(f"Config file {config_path_or_dict} not found.") 32 | raise 33 | except json.JSONDecodeError: 34 | logging.error(f"Error decoding JSON from {config_path_or_dict}") 35 | raise 36 | except Exception as e: 37 | logging.error( 38 | f"An unexpected error occurred when loading {config_path_or_dict}: {str(e)}" 39 | ) 40 | raise 41 | elif isinstance(config_path_or_dict, dict): 42 | # If config is a dictionary, assume it's already loaded and return it directly 43 | logging.debug("Using provided config dictionary") 44 | return config_path_or_dict 45 | else: 46 | raise ValueError( 47 | "Invalid config type. Config must be a file path or a dictionary." 48 | ) 49 | 50 | 51 | def load_module_from_file(module_name: str, file_path: str): 52 | """ 53 | Dynamically load a module from a Python file. 54 | 55 | Arguments: 56 | module_name -- the name to give to the loaded module 57 | file_path -- the path to the Python file 58 | 59 | Returns: 60 | Module -- The loaded module. 61 | 62 | Raises: 63 | Exception -- If there is an error loading the module. 64 | """ 65 | try: 66 | spec = importlib.util.spec_from_file_location(module_name, file_path) 67 | module = importlib.util.module_from_spec(spec) 68 | spec.loader.exec_module(module) 69 | return module 70 | except Exception as e: 71 | logging.error( 72 | f"Failed to load module: {module_name} from {file_path}. Error: {str(e)}" 73 | ) 74 | raise 75 | 76 | 77 | def load_modules_from_directory(directory_path: str): 78 | """ 79 | Dynamically load all modules in a directory. 80 | 81 | Arguments: 82 | directory_path -- the path to the directory 83 | 84 | Returns: 85 | Dict[str, Module] -- A dictionary of loaded modules. 86 | """ 87 | modules = {} 88 | for filename in os.listdir(directory_path): 89 | if filename.endswith(".py") and filename != "__init__.py": 90 | path = os.path.join(directory_path, filename) 91 | module_name = filename[:-3] # remove .py extension 92 | try: 93 | modules[module_name] = load_module_from_file(module_name, path) 94 | logging.info(f"Successfully loaded module: {module_name}") 95 | except Exception as e: 96 | logging.error(f"Failed to load module: {module_name}. Error: {str(e)}") 97 | raise 98 | return modules 99 | 100 | 101 | def main(config_path_or_dict: Any, module_dir_path: str) -> None: 102 | """ 103 | Main function to run the load module standalone. 104 | 105 | Arguments: 106 | config_path_or_dict -- the config file path or dictionary 107 | module_dir_path -- the directory containing the modules 108 | """ 109 | try: 110 | config = load_json_config(config_path_or_dict) 111 | load_modules_from_directory(module_dir_path) 112 | except Exception as e: 113 | logging.error(f"An unexpected error occurred: {str(e)}") 114 | raise 115 | 116 | 117 | if __name__ == "__main__": 118 | # Get the script absolute path and name 119 | script_dir = os.path.dirname(os.path.abspath(__file__)) 120 | script_name = os.path.basename(__file__) 121 | 122 | # Parse command-line arguments 123 | parser = argparse.ArgumentParser( 124 | description=f"Run the {script_name} module standalone." 125 | ) 126 | parser.add_argument( 127 | "--config-path", 128 | type=str, 129 | required=True, 130 | help="The config file path or JSON string", 131 | ) 132 | parser.add_argument( 133 | "--module-dir-path", 134 | type=str, 135 | required=True, 136 | help="The directory containing the modules", 137 | ) 138 | parser.add_argument( 139 | "--log-dir", 140 | default=os.path.join(script_dir, "logs"), 141 | help="Set the logging directory", 142 | ) 143 | parser.add_argument( 144 | "--log-file", default=f"{script_name}.log", help="Set the logging file name" 145 | ) 146 | parser.add_argument( 147 | "--log-level", 148 | choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], 149 | default="INFO", 150 | help="Set the logging level", 151 | ) 152 | args = parser.parse_args() 153 | 154 | # Set logging level based on command-line arguments 155 | log_level = getattr(logging, args.log_level.upper(), None) 156 | if not isinstance(log_level, int): 157 | raise ValueError(f"Invalid log level: {args.log_level}") 158 | 159 | # Start logging 160 | os.makedirs(args.log_dir, exist_ok=True) 161 | logging.basicConfig( 162 | filename=os.path.join(args.log_dir, args.log_file), 163 | format="%(asctime)s - [%(levelname)s] - %(message)s", 164 | datefmt="%Y-%m-%d %H:%M:%S", 165 | level=log_level, 166 | ) 167 | 168 | logging.info(f"Starting {script_name} script...") 169 | 170 | try: 171 | # Load the app_config JSON file 172 | config_path = args.config_path 173 | module_dir_path = args.module_dir_path 174 | 175 | # Call the main function 176 | main(config_path_or_dict=config_path, module_dir_path=module_dir_path) 177 | 178 | logging.info(f"{script_name} script completed successfully") 179 | except FileNotFoundError as e: 180 | logging.error(f"File not found: {str(e)}") 181 | raise 182 | except json.JSONDecodeError as e: 183 | logging.error(f"Error decoding JSON: {str(e)}") 184 | raise 185 | except Exception as e: 186 | logging.error(f"An unexpected error occurred: {str(e)}") 187 | raise 188 | -------------------------------------------------------------------------------- /.github/workflows/release-on-tag.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release Money4Band 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*.*.*" # Matches tags like 1.0.0, 2.1.1, etc. 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build: 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | os: [ubuntu-latest, macos-latest, windows-latest] # Build for Linux, macOS, and Windows 15 | architecture: [x64, arm64, arm/v7] # Target architectures 16 | exclude: 17 | - os: macos-latest 18 | architecture: arm/v7 # macOS doesn't support arm/v7 19 | - os: windows-latest 20 | architecture: arm/v7 # Windows doesn't support arm/v7 21 | 22 | steps: 23 | - name: Checkout the code 24 | uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 # Fetch all history for generating changelogs 27 | fetch-tags: true # Ensure all tags are fetched 28 | 29 | - name: Set up Python 3.12 30 | uses: actions/setup-python@v5 31 | with: 32 | python-version: '3.12' 33 | #cache: 'pip' # Enables caching for pip dependencies , it seems to fail in DinD containers for arm builds 34 | 35 | # Step to print platform details for diagnostic purposes 36 | - name: Check Python platform info 37 | shell: bash # Use bash shell for consistency 38 | run: | 39 | echo "Runner OS detected arch: $(uname -a)" 40 | echo "Python detected arch: $(python3 -m platform)" 41 | echo "Expected runner architecture: ${{ matrix.architecture }}" 42 | 43 | # Runners supported architectures use normal build process 44 | # Step: Install dependencies 45 | - name: Install dependencies 46 | if: matrix.os != 'ubuntu-latest' || (matrix.architecture != 'arm64' && matrix.architecture != 'arm/v7') 47 | shell: bash # Use bash shell for consistency 48 | run: | 49 | python -m pip install --upgrade pip 50 | pip install -r requirements.txt 51 | pip install pyinstaller # Install PyInstaller 52 | 53 | # Step: Build with PyInstaller 54 | - name: Build with PyInstaller 55 | if: matrix.os != 'ubuntu-latest' || (matrix.architecture != 'arm64' && matrix.architecture != 'arm/v7') 56 | shell: bash 57 | run: | 58 | pyinstaller --onedir \ 59 | --name Money4Band \ 60 | main.py \ 61 | --hidden-import colorama \ 62 | --hidden-import docker \ 63 | --hidden-import requests \ 64 | --hidden-import pyyaml \ 65 | --hidden-import psutil \ 66 | --hidden-import yaml \ 67 | --hidden-import secrets \ 68 | --add-data ".resources:.resources" \ 69 | --add-data "config:config" \ 70 | --add-data "utils:utils" \ 71 | --add-data "template:template" \ 72 | --add-data "LICENSE:LICENSE" \ 73 | --add-data "README.md:README.md" \ 74 | --contents-directory "." \ 75 | -y 76 | 77 | # Runners unsupported architectures use docker build process 78 | # Setup QEMU and Docker Buildx only for Ubuntu runners for arch not supported by runners 79 | - name: Set up QEMU for cross-compilation 80 | if: matrix.os == 'ubuntu-latest' && (matrix.architecture == 'arm64' || matrix.architecture == 'arm/v7') 81 | uses: docker/setup-qemu-action@v3 82 | with: 83 | platforms: all 84 | 85 | - name: Set up Docker Buildx 86 | if: matrix.os == 'ubuntu-latest' && (matrix.architecture == 'arm64' || matrix.architecture == 'arm/v7') 87 | uses: docker/setup-buildx-action@v3 88 | 89 | # Use Docker for cross-architecture builds for arch not supported by runners 90 | - name: Build with Docker for ARM architectures 91 | if: matrix.os == 'ubuntu-latest' && (matrix.architecture == 'arm64' || matrix.architecture == 'arm/v7') 92 | run: | 93 | docker build --file workflow-linux-arm64.dockerfile --platform linux/${{ matrix.architecture }} -t money4band-builder --cache-from type=gha --cache-to type=gha . 94 | docker run --platform linux/${{ matrix.architecture }} --rm -v $(pwd)/dist:/app/dist money4band-builder 95 | 96 | # Archive build artifacts for Windows 97 | - name: Archive build artifacts for release (Windows) 98 | if: runner.os == 'Windows' 99 | shell: pwsh 100 | run: | 101 | Compress-Archive -Path "dist\Money4Band\*" -DestinationPath "Money4Band-${{ matrix.os }}-${{ matrix.architecture }}-${{ github.ref_name }}.zip" 102 | 103 | # Archive build artifacts for Unix (Linux/macOS) 104 | - name: Archive build artifacts for release (Unix) 105 | if: runner.os != 'Windows' 106 | shell: bash 107 | run: | 108 | if [ "${{ matrix.architecture }}" == "arm/v7" ]; then 109 | tar -czvf "Money4Band-${{ matrix.os }}-armv7-${{ github.ref_name }}.tar.gz" dist/Money4Band 110 | else 111 | tar -czvf "Money4Band-${{ matrix.os }}-${{ matrix.architecture }}-${{ github.ref_name }}.tar.gz" dist/Money4Band 112 | fi 113 | 114 | # Upload build artifacts (excluded arm/v7) 115 | - name: Upload build artifacts 116 | uses: actions/upload-artifact@v4 117 | if: matrix.architecture != 'arm/v7' 118 | with: 119 | name: "Money4Band-${{ matrix.os }}-${{ matrix.architecture }}-${{ github.ref_name }}" 120 | path: | 121 | Money4Band-${{ matrix.os }}-${{ matrix.architecture }}-${{ github.ref_name }}.zip 122 | Money4Band-${{ matrix.os }}-${{ matrix.architecture }}-${{ github.ref_name }}.tar.gz 123 | if-no-files-found: error # Fail if no files are found 124 | 125 | # Upload build artifacts for arm/v7 126 | - name: Upload build artifacts 127 | uses: actions/upload-artifact@v4 128 | if: matrix.architecture == 'arm/v7' 129 | with: 130 | name: "Money4Band-${{ matrix.os }}-armv7-${{ github.ref_name }}" 131 | path: | 132 | Money4Band-${{ matrix.os }}-armv7-${{ github.ref_name }}.zip 133 | Money4Band-${{ matrix.os }}-armv7-${{ github.ref_name }}.tar.gz 134 | if-no-files-found: error # Fail if no files are found 135 | 136 | release: 137 | runs-on: ubuntu-latest 138 | needs: build 139 | steps: 140 | - name: Checkout the code 141 | uses: actions/checkout@v4 142 | with: 143 | fetch-depth: 0 # Fetch all history for generating changelogs 144 | fetch-tags: true # Ensure all tags are fetched 145 | 146 | - name: Download build artifacts 147 | uses: actions/download-artifact@v4 148 | with: 149 | path: ./artifacts 150 | 151 | - name: Generate Changelog 152 | id: changelog 153 | uses: heinrichreimer/action-github-changelog-generator@v2.3 154 | with: 155 | token: ${{ secrets.GITHUB_TOKEN }} 156 | output: CHANGELOG.md 157 | onlyLastTag: true # Only generate changelog for the last tag 158 | 159 | - name: Create a GitHub release 160 | uses: softprops/action-gh-release@v2 161 | if: startsWith(github.ref, 'refs/tags/') 162 | with: 163 | name: "Money4Band ${{ github.ref_name }}" 164 | tag_name: "${{ github.ref_name }}" 165 | body_path: "./CHANGELOG.md" 166 | files: "./artifacts/**/*" 167 | prerelease: true # Set to true so that the release is marked as a pre-release 168 | env: 169 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 170 | -------------------------------------------------------------------------------- /tests/test_fn_install_docker.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import sys 4 | import unittest 5 | from unittest.mock import call, patch 6 | 7 | # Ensure the parent directory is in the sys.path 8 | script_dir = os.path.dirname(os.path.abspath(__file__)) 9 | parent_dir = os.path.dirname(script_dir) 10 | sys.path.append(parent_dir) 11 | 12 | from utils.fn_install_docker import ( 13 | install_docker_linux, 14 | install_docker_macos, 15 | install_docker_windows, 16 | is_docker_installed, 17 | main, 18 | ) 19 | 20 | 21 | class TestFnInstallDocker(unittest.TestCase): 22 | @patch("utils.fn_install_docker.detect_os") 23 | @patch("utils.fn_install_docker.detect_architecture") 24 | @patch("utils.fn_install_docker.subprocess.run") 25 | def test_is_docker_installed( 26 | self, mock_run, mock_detect_architecture, mock_detect_os 27 | ): 28 | mock_run.side_effect = subprocess.CalledProcessError(1, "docker") 29 | mock_detect_os.return_value = {"os_type": "linux"} 30 | mock_detect_architecture.return_value = {"dkarch": "amd64"} 31 | 32 | m4b_config = {"system": {"sleep_time": 1}} 33 | result = is_docker_installed(m4b_config) 34 | self.assertFalse(result) 35 | 36 | mock_run.side_effect = None 37 | mock_run.return_value = subprocess.CompletedProcess( 38 | args=["docker", "--version"], returncode=0 39 | ) 40 | result = is_docker_installed(m4b_config) 41 | self.assertTrue(result) 42 | 43 | @patch("utils.fn_install_docker.download_file") 44 | @patch("utils.fn_install_docker.subprocess.run") 45 | @patch("utils.fn_install_docker.os.remove") 46 | def test_install_docker_linux(self, mock_remove, mock_run, mock_download_file): 47 | files_path = "/fake/path" 48 | mock_run.return_value = subprocess.CompletedProcess( 49 | args=["sudo", "sh", "get-docker.sh"], returncode=0 50 | ) 51 | 52 | install_docker_linux(files_path) 53 | 54 | mock_download_file.assert_called_once_with( 55 | "https://get.docker.com", os.path.join(files_path, "get-docker.sh") 56 | ) 57 | mock_run.assert_has_calls( 58 | [ 59 | call( 60 | ["sudo", "sh", os.path.join(files_path, "get-docker.sh")], 61 | check=True, 62 | ) 63 | ] 64 | ) 65 | mock_remove.assert_called_once_with(os.path.join(files_path, "get-docker.sh")) 66 | 67 | @patch("utils.fn_install_docker.download_file") 68 | @patch("utils.fn_install_docker.subprocess.run") 69 | @patch("utils.fn_install_docker.os.remove") 70 | @patch("utils.fn_install_docker.os.getenv", return_value="C:\\Program Files") 71 | def test_install_docker_windows( 72 | self, mock_getenv, mock_remove, mock_run, mock_download_file 73 | ): 74 | files_path = "/fake/path" 75 | 76 | # Mock subprocess.run to simulate a successful installation and launch of Docker Desktop 77 | mock_run.side_effect = [ 78 | subprocess.CompletedProcess(args=[], returncode=0), # For installation 79 | subprocess.CompletedProcess( 80 | args=[], returncode=0 81 | ), # For launching Docker Desktop 82 | ] 83 | 84 | # Call the function to install Docker on Windows 85 | install_docker_windows(files_path) 86 | 87 | # Assert that the download_file function was called with the correct arguments 88 | mock_download_file.assert_called_once_with( 89 | "https://desktop.docker.com/win/stable/Docker%20Desktop%20Installer.exe", 90 | os.path.join(files_path, "DockerInstaller.exe"), 91 | ) 92 | 93 | # Assert that subprocess.run was called with the correct arguments to install Docker 94 | mock_run.assert_any_call( 95 | [ 96 | os.path.join(files_path, "DockerInstaller.exe"), 97 | "install", 98 | "--accept-license", 99 | "--quiet", 100 | ], 101 | stdout=subprocess.PIPE, 102 | stderr=subprocess.PIPE, 103 | universal_newlines=True, 104 | shell=True, 105 | check=True, 106 | ) 107 | 108 | # Assert that subprocess.run was called with the correct arguments to launch Docker Desktop 109 | mock_run.assert_any_call( 110 | [ 111 | os.path.join( 112 | os.getenv("ProgramFiles"), "Docker", "Docker", "Docker Desktop.exe" 113 | ) 114 | ], 115 | shell=True, 116 | ) 117 | 118 | # Assert that the installer executable was removed after installation 119 | mock_remove.assert_called_once_with( 120 | os.path.join(files_path, "DockerInstaller.exe") 121 | ) 122 | 123 | @patch("utils.fn_install_docker.download_file") 124 | @patch("utils.fn_install_docker.subprocess.run") 125 | def test_install_docker_macos(self, mock_run, mock_download_file): 126 | files_path = "/fake/path" 127 | mock_run.return_value = subprocess.CompletedProcess( 128 | args=["sudo", "install", "--accept-license"], returncode=0 129 | ) 130 | 131 | install_docker_macos(files_path, intel_cpu=True) 132 | 133 | mock_download_file.assert_called_once_with( 134 | "https://desktop.docker.com/mac/main/amd64/Docker.dmg", 135 | os.path.join(files_path, "Docker.dmg"), 136 | ) 137 | mock_run.assert_has_calls( 138 | [ 139 | call( 140 | ["hdiutil", "attach", os.path.join(files_path, "Docker.dmg")], 141 | check=True, 142 | ), 143 | call( 144 | [ 145 | "sudo", 146 | "/Volumes/Docker/Docker.app/Contents/MacOS/install", 147 | "--accept-license", 148 | ], 149 | check=True, 150 | ), 151 | call(["hdiutil", "detach", "/Volumes/Docker"], check=True), 152 | call(["open", "/Applications/Docker.app"], check=True), 153 | ] 154 | ) 155 | 156 | @patch("utils.fn_install_docker.detect_os") 157 | @patch("utils.fn_install_docker.detect_architecture") 158 | @patch("utils.fn_install_docker.install_docker_linux") 159 | @patch("utils.fn_install_docker.install_docker_windows") 160 | @patch("utils.fn_install_docker.install_docker_macos") 161 | def test_main( 162 | self, 163 | mock_install_macos, 164 | mock_install_windows, 165 | mock_install_linux, 166 | mock_detect_architecture, 167 | mock_detect_os, 168 | ): 169 | mock_detect_os.return_value = {"os_type": "linux"} 170 | mock_detect_architecture.return_value = {"dkarch": "amd64"} 171 | 172 | app_config = {} 173 | m4b_config = {"files_path": "/fake/path"} 174 | user_config = {} 175 | 176 | with patch("builtins.input", return_value="y"): 177 | main(app_config, m4b_config, user_config) 178 | 179 | mock_install_linux.assert_called_once_with("/fake/path") 180 | 181 | mock_detect_os.return_value = {"os_type": "windows"} 182 | with patch("builtins.input", return_value="y"): 183 | main(app_config, m4b_config, user_config) 184 | 185 | mock_install_windows.assert_called_once_with("/fake/path") 186 | 187 | mock_detect_os.return_value = {"os_type": "darwin"} 188 | mock_detect_architecture.return_value = {"dkarch": "amd64"} 189 | with patch("builtins.input", return_value="y"): 190 | main(app_config, m4b_config, user_config) 191 | 192 | mock_install_macos.assert_called_once_with("/fake/path", intel_cpu=True) 193 | 194 | 195 | if __name__ == "__main__": 196 | unittest.main() 197 | -------------------------------------------------------------------------------- /template/user-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "user": { 3 | "Nickname": "yourNickname", 4 | "email": "your@mail.com" 5 | }, 6 | "device_info": { 7 | "device_name": "yourDeviceName", 8 | "os_type": "Linux", 9 | "detected_architecture": "amd64", 10 | "detected_docker_arch": "amd64" 11 | }, 12 | "apps": { 13 | "earnapp": { 14 | "enabled": false, 15 | "docker_platform": "linux/amd64", 16 | "uuid": "sdk-node-yourEARNAPPDeviceUUID" 17 | }, 18 | "honeygain": { 19 | "enabled": false, 20 | "docker_platform": "linux/amd64", 21 | "email": "yourHONEYGAINMail", 22 | "password": "yourHONEYGAINPw" 23 | }, 24 | "iproyalpawns": { 25 | "enabled": false, 26 | "docker_platform": "linux/amd64", 27 | "email": "yourIPROYALPAWNSMail", 28 | "password": "yourIPROYALPAWNSPw" 29 | }, 30 | "packetstream": { 31 | "enabled": false, 32 | "docker_platform": "linux/amd64", 33 | "cid": "yourPACKETSTREAMCID" 34 | }, 35 | "traffmonetizer": { 36 | "enabled": false, 37 | "docker_platform": "linux/amd64", 38 | "token": "yourTRAFFMONETIZERToken" 39 | }, 40 | "repocket": { 41 | "enabled": false, 42 | "docker_platform": "linux/amd64", 43 | "email": "yourREPOCKETMail", 44 | "apikey": "yourREPOCKETAPIKey" 45 | }, 46 | "earnfm": { 47 | "enabled": false, 48 | "docker_platform": "linux/amd64", 49 | "apikey": "yourEARNFMAPIKey" 50 | }, 51 | "proxyrack": { 52 | "enabled": false, 53 | "docker_platform": "linux/amd64", 54 | "apikey": "yourPROXYRACKAPIKey", 55 | "uuid": "yourPROXYRACKDeviceUUID" 56 | }, 57 | "proxylite": { 58 | "enabled": false, 59 | "docker_platform": "linux/amd64", 60 | "userid": "yourPROXYLITEUserID" 61 | }, 62 | "bitping": { 63 | "enabled": false, 64 | "docker_platform": "linux/amd64", 65 | "email": "yourBITPINGMail", 66 | "password": "yourBITPINGPw" 67 | }, 68 | "packetshare": { 69 | "enabled": false, 70 | "docker_platform": "linux/amd64", 71 | "email": "yourPACKETSHAREMail", 72 | "password": "yourPACKETSHAREPw" 73 | }, 74 | "speedshare": { 75 | "enabled": false, 76 | "docker_platform": "linux/amd64", 77 | "code": "yourSPEEDSHARECode", 78 | "uuid": "yourSPEEDSHAREDeviceUUID" 79 | }, 80 | "grass": { 81 | "enabled": false, 82 | "docker_platform": "linux/amd64", 83 | "email": "yourGRASSMail", 84 | "password": "yourGRASSPw" 85 | }, 86 | "gradient": { 87 | "enabled": false, 88 | "docker_platform": "linux/amd64", 89 | "email": "yourGRADIENTMail", 90 | "password": "yourGRADIENTPw" 91 | }, 92 | "dawn": { 93 | "enabled": false, 94 | "docker_platform": "linux/amd64", 95 | "email": "yourDAWNMail", 96 | "password": "yourDAWNPw", 97 | "ports": [5000] 98 | }, 99 | "teneo": { 100 | "enabled": false, 101 | "docker_platform": "linux/amd64", 102 | "email": "yourTENEOMail", 103 | "password": "yourTENEOPw" 104 | }, 105 | "mystnode": { 106 | "enabled": false, 107 | "docker_platform": "linux/amd64", 108 | "ports": [4449] 109 | }, 110 | "peer2profit": { 111 | "enabled": false, 112 | "docker_platform": "linux/amd64", 113 | "email": "yourPEER2PROFITMail" 114 | }, 115 | "proxybase": { 116 | "enabled": false, 117 | "docker_platform": "linux/amd64", 118 | "userid": "yourPROXYBASEUserID" 119 | }, 120 | "wipter": { 121 | "enabled": false, 122 | "docker_platform": "linux/amd64", 123 | "email": "yourWIPTERMail", 124 | "password": "yourWIPTERPw", 125 | "ports": [5900, 6080] 126 | } 127 | }, 128 | "m4b_dashboard": { 129 | "enabled": true, 130 | "ports": [8081] 131 | }, 132 | "proxies": { 133 | "enabled": false, 134 | "url": "", 135 | "url_example": "protocol://username:password@ip:port" 136 | }, 137 | "notifications": { 138 | "enabled": false, 139 | "url": "", 140 | "url_example": "yourApp:yourToken@yourWebHook" 141 | }, 142 | "compose_config_common": { 143 | "network": { 144 | "driver": "${NETWORK_DRIVER}", 145 | "subnet": "${NETWORK_SUBNET}", 146 | "netmask": "${NETWORK_NETMASK}" 147 | }, 148 | "proxy_service": { 149 | "container_name": "${DEVICE_NAME}_tun2socks", 150 | "hostname": "${DEVICE_NAME}_tun2socks", 151 | "image": "xjasonlyu/tun2socks", 152 | "environment": [ 153 | "LOGLEVEL=info", 154 | "PROXY=${STACK_PROXY_URL}", 155 | "EXTRA_COMMANDS=ip rule add iif lo ipproto udp dport 53 lookup main;" 156 | ], 157 | "cap_add": ["NET_ADMIN"], 158 | "privileged": true, 159 | "network_mode": "bridge", 160 | "dns": ["1.1.1.1", "8.8.8.8", "1.0.0.1", "8.8.4.4"], 161 | "ports": [], 162 | "volumes": ["/dev/net/tun:/dev/net/tun"], 163 | "restart": "always", 164 | "cpus": "${APP_CPU_LIMIT_BIG}", 165 | "mem_reservation": "${APP_MEM_RESERV_BIG}", 166 | "mem_limit": "${APP_MEM_LIMIT_BIG}" 167 | }, 168 | "watchtower_service": { 169 | "proxy_disabled": { 170 | "container_name": "${DEVICE_NAME}_watchtower", 171 | "hostname": "${DEVICE_NAME}_watchtower", 172 | "image": "containrrr/watchtower:latest", 173 | "environment": [ 174 | "WATCHTOWER_POLL_INTERVAL=14400", 175 | "WATCHTOWER_ROLLING_RESTART=true", 176 | "WATCHTOWER_NO_STARTUP_MESSAGE=true", 177 | "WATCHTOWER_CLEANUP=true", 178 | "WATCHTOWER_NOTIFICATION_URL=${WATCHTOWER_NOTIFICATION_URL}" 179 | ], 180 | "volumes": ["/var/run/docker.sock:/var/run/docker.sock"], 181 | "restart": "always", 182 | "cpus": "${APP_CPU_LIMIT_MEDIUM}", 183 | "mem_reservation": "${APP_MEM_RESERV_MEDIUM}", 184 | "mem_limit": "${APP_MEM_LIMIT_MEDIUM}" 185 | }, 186 | "proxy_enabled": { 187 | "container_name": "${DEVICE_NAME}_watchtower", 188 | "hostname": "${DEVICE_NAME}_watchtower", 189 | "image": "containrrr/watchtower:latest", 190 | "environment": [ 191 | "WATCHTOWER_POLL_INTERVAL=14400", 192 | "WATCHTOWER_ROLLING_RESTART=false", 193 | "WATCHTOWER_NO_STARTUP_MESSAGE=true", 194 | "WATCHTOWER_CLEANUP=true", 195 | "WATCHTOWER_NOTIFICATION_URL=${WATCHTOWER_NOTIFICATION_URL}" 196 | ], 197 | "volumes": ["/var/run/docker.sock:/var/run/docker.sock"], 198 | "restart": "always", 199 | "cpus": "${APP_CPU_LIMIT_MEDIUM}", 200 | "mem_reservation": "${APP_MEM_RESERV_MEDIUM}", 201 | "mem_limit": "${APP_MEM_LIMIT_MEDIUM}" 202 | } 203 | }, 204 | "m4b_dashboard_service": { 205 | "container_name": "${DEVICE_NAME}_m4b_dashboard", 206 | "hostname": "${DEVICE_NAME}_m4b_dashboard", 207 | "image": "nginx:alpine-slim", 208 | "volumes": [ 209 | "./.resources/.www:/usr/share/nginx/html", 210 | "./.resources/.assets:/usr/share/nginx/html/.images:ro", 211 | "./config/app-config.json:/usr/share/nginx/html/.config/app-config.json:ro" 212 | ], 213 | "ports": ["${M4B_DASHBOARD_PORT}:80"], 214 | "restart": "always", 215 | "cpus": "${APP_CPU_LIMIT_LITTLE}", 216 | "mem_reservation": "${APP_MEM_RESERV_LITTLE}", 217 | "mem_limit": "${APP_MEM_LIMIT_LITTLE}" 218 | } 219 | }, 220 | "resource_limits": { 221 | "app_cpu_limit_little": 0.8, 222 | "app_cpu_limit_medium": 1, 223 | "app_cpu_limit_big": 2, 224 | "app_cpu_limit_huge": 4, 225 | "ram_cap_mb_default": 6144, 226 | "app_mem_reserv_little": "64m", 227 | "app_mem_limit_little": "128m", 228 | "app_mem_reserv_medium": "128m", 229 | "app_mem_limit_medium": "256m", 230 | "app_mem_reserv_big": "256m", 231 | "app_mem_limit_big": "512m", 232 | "app_mem_reserv_huge": "512m", 233 | "app_mem_limit_huge": "1024m" 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /utils/helper.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | import logging 3 | import os 4 | import platform 5 | import subprocess 6 | import sys 7 | import threading 8 | import time 9 | from itertools import cycle 10 | 11 | from colorama import Fore, Style 12 | 13 | 14 | def is_user_root(): 15 | """ 16 | Check if the current user is the root user on Linux. 17 | On macOS and Windows, it always returns False since we don't manage Docker groups. 18 | """ 19 | return os.geteuid() == 0 if platform.system().lower() == "linux" else False 20 | 21 | 22 | def is_user_in_docker_group(): 23 | """ 24 | Check if the current user is in the Docker group on Linux. 25 | This function is skipped on Windows and macOS. 26 | """ 27 | if platform.system().lower() != "linux": 28 | return True 29 | # use getpass.getuser() instead of os.getlogin() as it is more robust 30 | user = getpass.getuser() 31 | logging.info(f"Detected user: {user}") 32 | logging.info(f"Checking if user '{user}' is in the Docker group...") 33 | groups = subprocess.run( 34 | ["groups", user], check=False, capture_output=True, text=True 35 | ) 36 | return "docker" in groups.stdout 37 | 38 | 39 | def create_docker_group_if_needed(): 40 | """ 41 | Create the Docker group if it doesn't exist and add the current user to it on Linux. 42 | This function is skipped on Windows and macOS. 43 | """ 44 | if platform.system().lower() != "linux": 45 | return 46 | 47 | try: 48 | if ( 49 | subprocess.run( 50 | ["getent", "group", "docker"], check=False, capture_output=True 51 | ).returncode 52 | != 0 53 | ): 54 | logging.info( 55 | f"{Fore.YELLOW}Docker group does not exist. Creating it...{Style.RESET_ALL}" 56 | ) 57 | subprocess.run(["sudo", "groupadd", "docker"], check=True) 58 | logging.info( 59 | f"{Fore.GREEN}Docker group created successfully.{Style.RESET_ALL}" 60 | ) 61 | 62 | # use getpass.getuser() instead of os.getlogin() as it is more robust 63 | user = getpass.getuser() 64 | logging.info(f"Adding user '{user}' to Docker group...") 65 | subprocess.run(["sudo", "usermod", "-aG", "docker", user], check=True) 66 | logging.info( 67 | f"{Fore.GREEN}User '{user}' added to Docker group. Please log out and log back in.{Style.RESET_ALL}" 68 | ) 69 | except subprocess.CalledProcessError as e: 70 | logging.error( 71 | f"{Fore.RED}Failed to add user to Docker group: {e}{Style.RESET_ALL}" 72 | ) 73 | raise RuntimeError("Failed to add user to Docker group.") from e 74 | 75 | 76 | def run_docker_command(command, use_sudo=False): 77 | """ 78 | Run a Docker command, optionally using sudo, and handle errors gracefully. 79 | 80 | Args: 81 | command (list): The Docker command to run. 82 | use_sudo (bool): Whether to prepend 'sudo' to the command. 83 | 84 | Returns: 85 | int: The exit code of the command. 86 | """ 87 | if use_sudo and platform.system().lower() == "linux": 88 | command.insert(0, "sudo") 89 | 90 | logging.info(f"Running command: {' '.join(command)}") 91 | 92 | try: 93 | result = subprocess.run(command, capture_output=True, text=True, check=False) 94 | if result.returncode == 0: 95 | logging.info(result.stdout) 96 | else: 97 | logging.error(f"Command failed with exit code {result.returncode}") 98 | logging.error(result.stderr) 99 | print(f"{Fore.RED}Error: {result.stderr.strip()}{Style.RESET_ALL}") 100 | return result.returncode 101 | except Exception as e: 102 | logging.error(f"{Fore.RED}Failed to run command: {e}{Style.RESET_ALL}") 103 | print(f"{Fore.RED}Unexpected error: {e}{Style.RESET_ALL}") 104 | raise RuntimeError(f"Command failed: {e}") 105 | 106 | 107 | def setup_service( 108 | service_name="docker.binfmt", 109 | service_file_path="./.resources/.files/docker.binfmt.service", 110 | ): 111 | """ 112 | Set up a service on Linux systems, defaulting to setting up the Docker binfmt service. 113 | 114 | Args: 115 | service_name (str): The name of the service to set up. Default is "docker.binfmt". 116 | service_file_path (str): The path to the service file. Default is "./.resources/.files/docker.binfmt.service". 117 | """ 118 | systemd_service_file = f"/etc/systemd/system/{service_name}.service" 119 | sysv_init_file = f"/etc/init.d/{service_name}" 120 | 121 | try: 122 | # Check if the service is already enabled and running 123 | if platform.system().lower() == "linux": 124 | if os.path.exists("/etc/systemd/system"): 125 | result = subprocess.run( 126 | ["systemctl", "is-active", service_name], 127 | check=False, 128 | capture_output=True, 129 | text=True, 130 | ) 131 | if result.stdout.strip() == "active": 132 | logging.info( 133 | f"{Fore.GREEN}{service_name} is already active and running.{Style.RESET_ALL}" 134 | ) 135 | return 136 | elif os.path.exists("/etc/init.d"): 137 | result = subprocess.run( 138 | ["service", service_name, "status"], 139 | check=False, 140 | capture_output=True, 141 | text=True, 142 | ) 143 | if "running" in result.stdout: 144 | logging.info( 145 | f"{Fore.GREEN}{service_name} is already active and running.{Style.RESET_ALL}" 146 | ) 147 | return 148 | 149 | # Copy service file and enable service 150 | if os.path.exists("/etc/systemd/system"): 151 | if not os.path.exists(systemd_service_file): 152 | logging.info(f"Copying service file to {systemd_service_file}") 153 | subprocess.run( 154 | ["sudo", "cp", service_file_path, systemd_service_file], check=True 155 | ) 156 | subprocess.run(["sudo", "systemctl", "daemon-reload"], check=True) 157 | subprocess.run( 158 | ["sudo", "systemctl", "enable", service_name], check=True 159 | ) 160 | subprocess.run(["sudo", "systemctl", "start", service_name], check=False) 161 | elif os.path.exists("/etc/init.d"): 162 | if not os.path.exists(sysv_init_file): 163 | logging.info(f"Copying service file to {sysv_init_file}") 164 | subprocess.run( 165 | ["sudo", "cp", service_file_path, sysv_init_file], check=True 166 | ) 167 | subprocess.run(["sudo", "chmod", "+x", sysv_init_file], check=True) 168 | subprocess.run( 169 | ["sudo", "update-rc.d", service_name, "defaults"], check=True 170 | ) 171 | subprocess.run(["sudo", "service", service_name, "start"], check=False) 172 | 173 | logging.info(f"{Fore.GREEN}{service_name} setup and started.{Style.RESET_ALL}") 174 | 175 | except subprocess.CalledProcessError as e: 176 | logging.error(f"Failed to setup {service_name}: {str(e)}") 177 | raise RuntimeError(f"Failed to setup {service_name}: {str(e)}") 178 | 179 | 180 | def ensure_service( 181 | service_name="docker.binfmt", 182 | service_file_path="./.resources/.files/docker.binfmt.service", 183 | ): 184 | """ 185 | Ensure that a service is installed and running, defaulting to the Docker binfmt service. 186 | 187 | Args: 188 | service_name (str): The name of the service to ensure. Default is "docker.binfmt". 189 | service_file_path (str): The path to the service file. Default is './.resources/.files/docker.binfmt.service'. 190 | """ 191 | logging.info(f"Ensuring {service_name} service is installed and running.") 192 | try: 193 | setup_service(service_name=service_name, service_file_path=service_file_path) 194 | logging.info( 195 | f"{Fore.GREEN}{service_name} setup completed successfully.{Style.RESET_ALL}" 196 | ) 197 | except Exception as e: 198 | logging.error(f"Failed to ensure {service_name} service: {str(e)}") 199 | raise RuntimeError(f"Failed to ensure {service_name} service: {str(e)}") 200 | 201 | 202 | def show_spinner(message: str, event: threading.Event): 203 | """ 204 | Display a spinner animation in the console to indicate progress. 205 | 206 | Args: 207 | message (str): The message to display alongside the spinner. 208 | event (threading.Event): A threading event to stop the spinner. 209 | """ 210 | spinner = cycle(["|", "/", "-", "\\"]) 211 | sys.stdout.write(f"{message} ") 212 | sys.stdout.flush() 213 | while not event.is_set(): 214 | sys.stdout.write(next(spinner)) 215 | sys.stdout.flush() 216 | time.sleep(0.1) 217 | sys.stdout.write("\b") # Remove the spinner character 218 | sys.stdout.write("\b") # Clear the spinner when stopping 219 | sys.stdout.write("Done\n") # Print a completion message 220 | sys.stdout.flush() 221 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import locale 3 | import logging 4 | import os 5 | 6 | # Ensure the parent directory is in the sys.path 7 | import sys 8 | import time 9 | from logging.handlers import RotatingFileHandler 10 | 11 | from colorama import Fore, Style, just_fix_windows_console 12 | 13 | from utils import detector, dumper, loader 14 | from utils.cls import cls 15 | from utils.fn_reset_config import main as reset_main 16 | from utils.updater import check_update_available 17 | 18 | script_dir = os.path.dirname(os.path.abspath(__file__)) 19 | parent_dir = os.path.dirname(script_dir) 20 | if parent_dir not in sys.path: 21 | sys.path.append(parent_dir) 22 | 23 | # Import the module from the parent directory 24 | 25 | 26 | def mainmenu( 27 | m4b_config_path: str, 28 | apps_config_path: str, 29 | user_config_path: str, 30 | utils_dir_path: str, 31 | ) -> None: 32 | """ 33 | Main menu of the script. 34 | 35 | Arguments: 36 | m4b_config_path -- the path to the m4b config file 37 | apps_config_path -- the path to the apps config file 38 | user_config_path -- the path to the user config file 39 | utils_dir_path -- the path to the utils directory 40 | """ 41 | try: 42 | logging.debug("Initializing colorama") 43 | just_fix_windows_console() 44 | logging.info("Colorama initialized successfully") 45 | except Exception as e: 46 | logging.error(f"Error initializing colorama: {str(e)}") 47 | raise 48 | 49 | while True: 50 | try: 51 | logging.info("Loading configurations") 52 | m4b_config = loader.load_json_config(m4b_config_path) 53 | user_config = loader.load_json_config(user_config_path) 54 | logging.info("Configurations loaded successfully") 55 | except FileNotFoundError as e: 56 | logging.error(f"File not found: {str(e)}") 57 | raise 58 | except Exception as e: 59 | logging.error(f"An error occurred while loading configurations: {str(e)}") 60 | raise 61 | 62 | try: 63 | logging.debug("Loading main menu") 64 | sleep_time = m4b_config.get("system", {}).get("sleep_time", 2) 65 | 66 | logging.debug("Detecting OS and architecture") 67 | system_info = { 68 | **detector.detect_os(m4b_config_path), 69 | **detector.detect_architecture(m4b_config_path), 70 | } 71 | 72 | # Update user_config with detected OS, architecture, and docker architecture 73 | device_info = user_config.setdefault("device_info", {}) 74 | device_info["os_type"] = system_info.get("os_type") 75 | device_info["detected_architecture"] = system_info.get("arch") 76 | device_info["detected_docker_arch"] = system_info.get("dkarch") 77 | 78 | # Add default platform for apps if not already present 79 | for app_name in user_config.get("apps", {}): 80 | app_config = user_config["apps"].setdefault(app_name, {}) 81 | app_config["docker_platform"] = ( 82 | f"linux/{device_info['detected_docker_arch']}" 83 | ) 84 | 85 | dumper.write_json(user_config, user_config_path) 86 | logging.info(f"System info and default platform stored: {device_info}") 87 | 88 | logging.debug("Calculating resources limits based on system") 89 | detector.calculate_resource_limits( 90 | user_config_path_or_dict=user_config_path 91 | ) 92 | 93 | # Load the functions from the passed tools dir 94 | logging.debug(f"Loading modules from {utils_dir_path}") 95 | m4b_tools_modules = loader.load_modules_from_directory(utils_dir_path) 96 | logging.info(f"Successfully loaded modules from {utils_dir_path}") 97 | cls() 98 | print(f"{Fore.GREEN}----------------------------------------------") 99 | print( 100 | f"{Fore.GREEN}MONEY4BAND AUTOMATIC GUIDED SETUP v{m4b_config.get('project')['project_version']}{Style.RESET_ALL}" 101 | ) 102 | check_update_available(m4b_config) 103 | print( 104 | f"{Fore.GREEN}----------------------------------------------{Style.RESET_ALL}" 105 | ) 106 | print( 107 | f"{Fore.YELLOW}Support the M4B development <3 check the donation options in the README, on GitHub or in our Discord. Every bit helps!" 108 | ) 109 | print( 110 | f"{Fore.MAGENTA}Join our Discord community for updates, help and discussions: {m4b_config.get('project')['ds_project_server_url']}{Style.RESET_ALL}" 111 | ) 112 | print("----------------------------------------------") 113 | print(f"Detected OS type: {system_info.get('os_type')}") 114 | print(f"Detected architecture: {system_info.get('arch')}") 115 | print( 116 | f"Docker {system_info.get('dkarch')} image architecture will be used if the app's image permits it" 117 | ) 118 | print("----------------------------------------------") 119 | except Exception as e: 120 | logging.error(f"An error occurred while setting up the menu: {str(e)}") 121 | raise 122 | try: 123 | logging.debug("Loading menu options from config file") 124 | menu_options = m4b_config["menu"] 125 | 126 | for i, option in enumerate(menu_options, start=1): 127 | print(f"{i}. {option['label']}") 128 | 129 | choice = input("Select an option and press Enter: ") 130 | 131 | try: 132 | choice = int(choice) 133 | except ValueError: 134 | print( 135 | f"Invalid input. Please select a menu option between 1 and {len(menu_options)}." 136 | ) 137 | time.sleep(sleep_time) 138 | continue 139 | 140 | if 1 <= choice <= len(menu_options): 141 | function_label = menu_options[choice - 1]["label"] 142 | function_name = menu_options[choice - 1]["function"] 143 | logging.info( 144 | f"User selected menu option number {choice} that corresponds to menu item {function_label}" 145 | ) 146 | m4b_tools_modules[function_name].main( 147 | apps_config_path, m4b_config_path, user_config_path 148 | ) 149 | else: 150 | print( 151 | f"Invalid input. Please select a menu option between 1 and {len(menu_options)}." 152 | ) 153 | time.sleep(sleep_time) 154 | except Exception as e: 155 | logging.error(f"An error occurred while processing the menu: {str(e)}") 156 | raise 157 | 158 | 159 | def main(): 160 | # Get the script absolute path and name 161 | script_dir = os.path.dirname(os.path.abspath(__file__)) 162 | script_name = os.path.basename(__file__) 163 | 164 | # Parse command-line arguments 165 | parser = argparse.ArgumentParser(description="Run the script.") 166 | parser.add_argument( 167 | "--config-dir", 168 | default=os.path.join(script_dir, "config"), 169 | help="Set the config directory", 170 | ) 171 | parser.add_argument( 172 | "--config-m4b-file", 173 | default="m4b-config.json", 174 | help="Set the m4b config file name", 175 | ) 176 | parser.add_argument( 177 | "--config-usr-file", 178 | default="user-config.json", 179 | help="Set the user config file name", 180 | ) 181 | parser.add_argument( 182 | "--config-app-file", 183 | default="app-config.json", 184 | help="Set the apps config file name", 185 | ) 186 | parser.add_argument( 187 | "--utils-dir", 188 | default=os.path.join(script_dir, "utils"), 189 | help="Set the m4b tools directory", 190 | ) 191 | parser.add_argument( 192 | "--requirements-path", 193 | default=os.path.join(script_dir, "requirements.toml"), 194 | help="Set the requirements path", 195 | ) 196 | parser.add_argument( 197 | "--log-level", 198 | choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], 199 | default="INFO", 200 | help="Set the logging level", 201 | ) 202 | parser.add_argument( 203 | "--log-dir", 204 | default=os.path.join(script_dir, "logs"), 205 | help="Set the logging directory", 206 | ) 207 | parser.add_argument( 208 | "--log-file", default="m4b.log", help="Set the logging file name" 209 | ) 210 | parser.add_argument( 211 | "--template-user-config-path", 212 | default=os.path.join(script_dir, "template", "user-config.json"), 213 | help="Set the template user config file path", 214 | ) 215 | args = parser.parse_args() 216 | 217 | # Address possible locale issues that use different notations for decimal numbers and so on 218 | locale.setlocale(locale.LC_ALL, "C") 219 | 220 | # Set logging level based on command-line arguments 221 | log_level = getattr(logging, args.log_level.upper(), None) 222 | if not isinstance(log_level, int): 223 | raise ValueError(f"Invalid log level: {args.log_level}") 224 | 225 | # Setup logging with rotation and reporting date time, error level, and message 226 | os.makedirs(args.log_dir, exist_ok=True) 227 | log_file_path = os.path.join(args.log_dir, args.log_file) 228 | rotating_handler = RotatingFileHandler( 229 | log_file_path, maxBytes=1 * 1024 * 1024, backupCount=3 230 | ) # 1 MB per file, keep 3 backups 231 | rotating_handler.setFormatter( 232 | logging.Formatter( 233 | "%(asctime)s - [%(levelname)s] - %(message)s", datefmt="%Y-%m-%d %H:%M:%S" 234 | ) 235 | ) 236 | rotating_handler.setLevel(log_level) 237 | 238 | logging.basicConfig(level=log_level, handlers=[rotating_handler]) 239 | 240 | logging.info(f"Starting {script_name} script...") 241 | 242 | # Check if user config exists; if not, reset it from the template 243 | user_config_path = os.path.join(args.config_dir, args.config_usr_file) 244 | if not os.path.exists(user_config_path): 245 | logging.info("User config not found. Resetting from template...") 246 | # Call the reset_config main function 247 | reset_main( 248 | app_config_path=None, 249 | m4b_config_path=None, 250 | user_config_path=user_config_path, 251 | src_path=args.template_user_config_path, 252 | dest_path=user_config_path, 253 | ) 254 | 255 | try: 256 | mainmenu( 257 | m4b_config_path=os.path.join(args.config_dir, args.config_m4b_file), 258 | apps_config_path=os.path.join(args.config_dir, args.config_app_file), 259 | user_config_path=user_config_path, 260 | utils_dir_path=args.utils_dir, 261 | ) 262 | except Exception as e: 263 | logging.error(f"An error occurred: {str(e)}") 264 | raise 265 | 266 | 267 | if __name__ == "__main__": 268 | main() 269 | -------------------------------------------------------------------------------- /.resources/.www/Home.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 5 | 6 | 7 |
30 |
31 |
54 |
30 |
31 |
54 |