/
Flipper
/
Marginal
Обзор
Документация
Войти
/
Flipper
/
Marginal
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
1
CI/CD
Аналитика
Безопасность
master
BUILD.py
847 строк
30 KB
Alex
Ceased tracking app_metadata.json and buildManifest.json. Now replaced with template files.
29 янв 2026, 15:03
29 янв 2026, 15:03
cb3a72b
Код
Авторство
О чём код?
""" Marginal Application Builder =========================== Build script that uses buildManifest.json for configuration management. Handles executable building, resource compilation, and file management. [NOTICE] This script is partly AI-generated. To be replaces/edited in later versions """ import datetime import json import os import random import shutil import string import subprocess import sys import time import PyInstaller.__main__ # Start timing startTime = time.time() class Tee: """ Duplicates output to both console and a log file for build process tracking. """ def __init__(self, *files): """ Initialize Tee with file objects. Args: *files: File objects to write to. """ self.files = files def write(self, obj): """ Write object to all registered files. Args: obj: Object to write (string). Returns: None """ for f in self.files: f.write(obj) f.flush() def flush(self): """ Flush all registered files. Returns: None """ for f in self.files: f.flush() def loadBuildManifest(): """ Load and validate buildManifest.json configuration file. Returns: dict -> Manifest dictionary with build configuration. """ manifestPath = os.path.join(os.path.dirname(os.path.abspath(__file__)), "buildManifest.json") try: with open(manifestPath, 'r') as f: manifest = json.load(f) # Validate required fields requiredFields = ["destinationFolder", "resourcesFolder", "metaFolder", "configFolder"] for field in requiredFields: if field not in manifest: raise ValueError(f"Missing required field in manifest: {field}") return manifest except FileNotFoundError: print(f"[ERROR] buildManifest.json not found at {manifestPath}") exit(1) except json.JSONDecodeError as e: print(f"[ERROR] Invalid JSON in buildManifest.json: {e}") exit(1) except ValueError as e: print(f"[ERROR] {e}") exit(1) def compileLocaleFiles(projectRoot, resourcesFolderName): """ Compile .po files into .mo files for locales specified in manifest.json. Args: projectRoot (str): Root directory of the project. resourcesFolderName (str): Name of the resources folder. Returns: bool -> True if compilation successful, False otherwise. """ print("\n=== Compiling Locale Files ===") # Path to locale manifest (different from build manifest!) localeManifestPath = os.path.join(projectRoot, resourcesFolderName, "locales", "manifest.json") if not os.path.exists(localeManifestPath): print(f"[ERROR] locale manifest.json not found at {localeManifestPath}") print(" This manifest is required for locale compilation.") return False try: with open(localeManifestPath, 'r') as f: localeManifest = json.load(f) except json.JSONDecodeError as e: print(f"[ERROR] Invalid JSON in locale manifest.json: {e}") return False # Check for required fields if "availableLocales" not in localeManifest: print("[ERROR] locale manifest.json missing 'availableLocales' field") return False if not localeManifest["availableLocales"]: print("[WARNING] No locales specified in locale manifest") return True # No locales to compile, but not a fatal error # Get default locale (English) defaultLocale = localeManifest.get("defaultLocale", "en") print(f"[INFO] Default locale: {defaultLocale} (will be skipped during compilation)") # Process each locale (skip default locale) localeBasePath = os.path.join(projectRoot, resourcesFolderName, "locales") allSuccessful = True for locale in localeManifest["availableLocales"]: localeCode = locale.get("code") if not localeCode: print(f"[WARNING] Locale entry missing 'code': {locale}") continue # Skip default locale (English) - no need to compile for build if localeCode == defaultLocale: print(f" [SKIP] Skipping default locale: {localeCode}") continue print(f"\n Processing non-default locale: {localeCode}") # Path to source .po file poDir = os.path.join(localeBasePath, localeCode, "LC_MESSAGES") poFile = os.path.join(poDir, "marginal.po") moFile = os.path.join(poDir, "marginal.mo") if not os.path.exists(poFile): print(f" [ERROR] .po file not found: {poFile}") allSuccessful = False continue try: # Run msguniq to ensure unique messages print(f" Running msguniq on: {poFile}") msguniqCmd = ["msguniq", poFile, "-o", poFile] result = subprocess.run(msguniqCmd, capture_output=True, text=True, check=False) if result.returncode != 0: print(f" [WARNING] msguniq failed with return code {result.returncode}") print(f" stderr: {result.stderr[:100]}...") # Run msgfmt to compile .po to .mo print(f" Compiling .po to .mo: {poFile}") msgfmtCmd = ["msgfmt", poFile, "-o", moFile] result = subprocess.run(msgfmtCmd, capture_output=True, text=True, check=True) print(f" [OK] Successfully compiled: {moFile}") except subprocess.CalledProcessError as e: print(f" [ERROR] msgfmt compilation failed: {e}") # Try to copy existing .mo file if available if os.path.exists(moFile): print(f" [WARNING] Using existing .mo file: {moFile}") else: print(f" [ERROR] No .mo file exists for locale {localeCode}") print(f" Aborting build due to missing .mo file for locale {localeCode}") return False except FileNotFoundError as e: print(f" [ERROR] Required tool not found: {e}") print(" Make sure gettext tools (msguniq, msgfmt) are installed and in PATH") # Try to copy existing .mo file if available if os.path.exists(moFile): print(f" [WARNING] Using existing .mo file: {moFile}") else: print(f" [ERROR] No .mo file exists for locale {localeCode}") print(f" Aborting build due to missing .mo file for locale {localeCode}") return False return allSuccessful def copyCompiledLocales(projectRoot, resourcesFolderName, outputDir): """ Copy compiled .mo files and locale manifest to the build directory. Args: projectRoot (str): Root directory of the project. resourcesFolderName (str): Name of the resources folder. outputDir (str): Destination build directory. Returns: bool -> True if copy successful, False otherwise. """ print("\n=== Copying Compiled Locales ===") localeBasePath = os.path.join(projectRoot, resourcesFolderName, "locales") localeManifestPath = os.path.join(localeBasePath, "manifest.json") if not os.path.exists(localeManifestPath): print(f"[ERROR] locale manifest.json not found at {localeManifestPath}") return False try: with open(localeManifestPath, 'r') as f: localeManifest = json.load(f) except json.JSONDecodeError as e: print(f"[ERROR] Invalid JSON in locale manifest.json: {e}") return False # Get default locale defaultLocale = localeManifest.get("defaultLocale", "en") # Copy locale manifest to resources/locales/ destManifestDir = os.path.join(outputDir, resourcesFolderName, "locales") os.makedirs(destManifestDir, exist_ok=True) destManifestPath = os.path.join(destManifestDir, "manifest.json") shutil.copy2(localeManifestPath, destManifestPath) print(f" [OK] Copied locale manifest to: {destManifestPath}") # Copy each locale's compiled .mo file (including default locale for runtime use) localesToCopy = [] # First, collect all locales that have .mo files for locale in localeManifest.get("availableLocales", []): localeCode = locale.get("code") if not localeCode: continue # Check if .mo file exists srcLocaleDir = os.path.join(localeBasePath, localeCode, "LC_MESSAGES") srcMoFile = os.path.join(srcLocaleDir, "marginal.mo") if os.path.exists(srcMoFile): localesToCopy.append((localeCode, srcLocaleDir, srcMoFile)) elif localeCode != defaultLocale: # Only warn for non-default locales print(f" [WARNING] .mo file not found for locale {localeCode} at {srcMoFile}") # Now copy all collected locales for localeCode, srcLocaleDir, srcMoFile in localesToCopy: # Destination paths - use "locales" destLocaleDir = os.path.join(outputDir, resourcesFolderName, "locales", localeCode, "LC_MESSAGES") os.makedirs(destLocaleDir, exist_ok=True) destMoFile = os.path.join(destLocaleDir, "marginal.mo") # Copy .mo file shutil.copy2(srcMoFile, destMoFile) if localeCode == defaultLocale: print(f" [INFO] Copied default locale .mo for {localeCode} to: {destMoFile}") else: print(f" [OK] Copied .mo for {localeCode} to: {destMoFile}") return True def checkMissingFiles(manifest, projectRoot): """ Check for missing files/folders based on manifest configuration. Args: manifest (dict): Build manifest configuration. projectRoot (str): Root directory of the project. Returns: bool -> True if all required files exist, False otherwise. """ allowMissing = manifest.get("allowMissing", {}) # Check meta files metaFolder = os.path.join(projectRoot, manifest["metaFolder"]) metaFiles = manifest.get("metaFiles", []) if not allowMissing.get("meta", False): for metaFile in metaFiles: filePath = os.path.join(metaFolder, metaFile) if not os.path.exists(filePath): print(f"[ERROR] Required meta file not found: {filePath}") return False # Check helper files if configured helpersFolder = os.path.join(projectRoot, "helpers") if not allowMissing.get("helpers", True): includeFiles = manifest.get("includeFiles", {}).get("helpers", {}) if isinstance(includeFiles, dict): for fileName, include in includeFiles.items(): if include and not os.path.exists(os.path.join(helpersFolder, fileName)): print(f"[ERROR] Required helper file not found: {fileName}") return False # Check config files if configured configFolder = os.path.join(projectRoot, manifest["configFolder"]) if not allowMissing.get("config", True): includeFiles = manifest.get("includeFiles", {}).get("config", {}) if isinstance(includeFiles, dict): for fileName, include in includeFiles.items(): if include and not os.path.exists(os.path.join(configFolder, fileName)): print(f"[ERROR] Required config file not found: {fileName}") return False return True def updateAppMetadata(manifest, localFolder): """ Update app_metadata.json with build information and user inputs. Args: manifest (dict): Build manifest configuration. localFolder (str): Path to local folder containing metadata. Returns: None """ metadataPath = os.path.join(localFolder, "app_metadata.json") if not os.path.exists(metadataPath): print(f"[WARNING] app_metadata.json not found at {metadataPath}") return with open(metadataPath, 'r') as f: metadata = json.load(f) # Set build timestamp and salt metadata['buildTimestamp'] = datetime.datetime.now().isoformat() metadata['salt'] = ''.join(random.choices(string.ascii_letters + string.digits, k=8)) # Get manifest values for defaults manifestVersion = manifest.get("version", "1.0.0") manifestVersionFlag = manifest.get("versionFlag", "PoC") manifestMinProfile = manifest.get("minimumProfileVersion", "1.0.0") manifestMinSave = manifest.get("minimumSaveVersion", "1.0.0") # Prompt user for overrides print("\n=== Setting App Metadata ===") # Version currentVersion = metadata.get('version', manifestVersion) version = input(f"Version [{currentVersion}]: ").strip() metadata['version'] = version if version else currentVersion # Version flag currentFlag = metadata.get('versionFlag', manifestVersionFlag) versionFlag = input(f"Version Flag [{currentFlag}]: ").strip() metadata['versionFlag'] = versionFlag if versionFlag else currentFlag # Compatibility settings compatibility = metadata.get('compatibility', {}) # Minimum profile version currentMinProfile = compatibility.get('min_profile', manifestMinProfile) minProfile = input(f"Minimum Profile Version [{currentMinProfile}]: ").strip() compatibility['min_profile'] = minProfile if minProfile else currentMinProfile # Minimum save version currentMinSave = compatibility.get('min_save', manifestMinSave) minSave = input(f"Minimum Save Version [{currentMinSave}]: ").strip() compatibility['min_save'] = minSave if minSave else currentMinSave metadata['compatibility'] = compatibility # Save updated metadata with open(metadataPath, 'w') as f: json.dump(metadata, f, indent=2) print(f"[OK] Updated metadata at: {metadataPath}") def compileResources(projectRoot): """ Compile .qrc resources using pyside6-rcc. Args: projectRoot (str): Root directory of the project. Returns: str | None -> Path to compiled resources file, or None if compilation failed. """ print("\n=== Compiling Resources ===") qrcFile = os.path.join(projectRoot, "Marginal.qrc") outputPy = os.path.join(projectRoot, "compiled_resources.py") if os.path.exists(qrcFile): try: subprocess.run(["pyside6-rcc", qrcFile, "-o", outputPy], check=True) print(f"[OK] Compiled resources: {qrcFile} -> {outputPy}") return outputPy except subprocess.CalledProcessError as e: print(f"[ERROR] Failed to compile resources: {e}") except FileNotFoundError: print("[ERROR] pyside6-rcc not found. Make sure PySide6 is installed.") else: print(f"[WARNING] Marginal.qrc not found at {qrcFile}") return None def copyDefaultConfig(localFolder, configFolder): """ Copy default configuration from local folder to config folder. Args: localFolder (str): Path to local folder containing default config. configFolder (str): Path to config folder. Returns: None """ print("\n=== Copying Default Config ===") defaultConfigSrc = os.path.join(localFolder, "app_defaultConfig.json") defaultConfigDst = os.path.join(configFolder, "config.json") if os.path.exists(defaultConfigSrc): os.makedirs(configFolder, exist_ok=True) shutil.copy2(defaultConfigSrc, defaultConfigDst) print(f"[OK] Copied: {defaultConfigSrc} -> {defaultConfigDst}") else: print(f"[WARNING] app_defaultConfig.json not found at {defaultConfigSrc}") def cleanPreviousBuild(buildsFolder): """ Clean previous build artifacts. Args: buildsFolder (str): Path to builds folder. Returns: None """ foldersToRemove = [ os.path.join(buildsFolder, "BUILD"), os.path.join(buildsFolder, "build_temp"), os.path.join(buildsFolder, "build_specs") ] for folder in foldersToRemove: if os.path.exists(folder): print(f"[CLEAN] Cleaning: {folder}") shutil.rmtree(folder, ignore_errors=True) def buildExecutable(manifest, projectRoot, buildsFolder, localFolder, configFolder, iconPath, compiledResourcesPath): """ Build executable using PyInstaller with manifest configuration. Args: manifest (dict): Build manifest configuration. projectRoot (str): Root directory of the project. buildsFolder (str): Path to builds folder. localFolder (str): Path to local folder. configFolder (str): Path to config folder. iconPath (str): Path to application icon. compiledResourcesPath (str | None): Path to compiled resources file. Returns: None """ print("\n=== Building Executable ===") # Create PyInstaller command - use the original method that worked cmd = [ os.path.join("src", "core.py"), "--name=Marginal", "--onedir", "--windowed", f"--icon={iconPath}", f"--distpath={os.path.join(buildsFolder, 'BUILD')}", f"--workpath={os.path.join(buildsFolder, 'build_temp')}", f"--specpath={os.path.join(buildsFolder, 'build_specs')}", "--noconfirm", ] # Add local folder files to the executable - USE ORIGINAL METHOD metaFolder = manifest["metaFolder"] cmd.append(f"--add-data={os.path.join(localFolder, '*')}{os.pathsep}{metaFolder}") # Add compiled resources if they exist if compiledResourcesPath and os.path.exists(compiledResourcesPath): cmd.append(f"--hidden-import=compiled_resources") print(f"Running PyInstaller with command:") print(" ".join(cmd)) # Run build PyInstaller.__main__.run(cmd) def copyAdditionalFolders(manifest, projectRoot, outputDir): """ Copy additional folders based on manifest configuration. Args: manifest (dict): Build manifest configuration. projectRoot (str): Root directory of the project. outputDir (str): Destination build directory. Returns: None """ print("\n=== Copying Additional Folders ===") includeFolders = manifest.get("includeFolders", []) includeFilesConfig = manifest.get("includeFiles", {}) for folderName in includeFolders: src = os.path.join(projectRoot, folderName) dest = os.path.join(outputDir, folderName) if not os.path.exists(src): if folderName in ["logs", "exports"]: # Create empty directories for logs and exports os.makedirs(dest, exist_ok=True) print(f"[FOLDER] Created empty: {folderName}") else: print(f"[FOLDER] Source not found, skipping: {folderName}") continue # Don't copy local folder (it's baked into executable) if folderName == manifest.get("metaFolder", "local"): print(f"[FOLDER] Skipping {folderName} (baked into executable)") continue # Check if folder has specific file inclusion rules in includeFiles if folderName not in includeFilesConfig: # Copy entire folder (default behavior) shutil.copytree(src, dest, dirs_exist_ok=True) print(f"[FOLDER] Copied entire folder: {folderName}") continue folderConfig = includeFilesConfig[folderName] if folderConfig is False: # Don't copy any files, just create empty directory os.makedirs(dest, exist_ok=True) print(f"[FOLDER] Created empty: {folderName}") continue # Skip to next folder elif folderConfig is True: # Copy entire folder shutil.copytree(src, dest, dirs_exist_ok=True) print(f"[FOLDER] Copied entire folder: {folderName}") continue # Skip to next folder elif isinstance(folderConfig, dict): # Copy only specified files os.makedirs(dest, exist_ok=True) filesCopied = 0 for fileName, shouldInclude in folderConfig.items(): if not shouldInclude: # Skip files marked as false continue # Handle nested paths (e.g., "templates_LOCALE/localeTemplate.po") srcFile = src destFile = dest # Split path if it contains directories if '/' in fileName or '\\' in fileName: # Handle nested directory structure pathParts = fileName.replace('\\', '/').split('/') fileNameOnly = pathParts[-1] # Build source and destination paths for part in pathParts[:-1]: srcFile = os.path.join(srcFile, part) destFile = os.path.join(destFile, part) # Ensure destination directory exists os.makedirs(destFile, exist_ok=True) srcFile = os.path.join(srcFile, fileNameOnly) destFile = os.path.join(destFile, fileNameOnly) else: # Simple file in root of folder srcFile = os.path.join(src, fileName) destFile = os.path.join(dest, fileName) if os.path.exists(srcFile): if os.path.isdir(srcFile): shutil.copytree(srcFile, destFile, dirs_exist_ok=True) else: shutil.copy2(srcFile, destFile) filesCopied += 1 print(f" [FILE] Copied: {fileName}") else: print(f" [WARNING] File not found: {fileName}") print(f"[FOLDER] Copied {filesCopied} files from: {folderName}") def handleLocaleTemplate(manifest, projectRoot): """ Handle locale template generation if configured in manifest. Args: manifest (dict): Build manifest configuration. projectRoot (str): Root directory of the project. Returns: list -> List of (relativePath, sourceFile) tuples for generated files. """ includeFiles = manifest.get("includeFiles", {}) helpersConfig = includeFiles.get("helpers", {}) generatedFiles = [] if isinstance(helpersConfig, dict) and helpersConfig.get("localeTemplate.po", False): print("\n=== Generating Locale Template ===") # Create locale folder in project projectLocaleFolder = os.path.join(projectRoot, "helpers", "templates_LOCALE") os.makedirs(projectLocaleFolder, exist_ok=True) # Import and run scanStrings in builder mode try: # Import the scanner module from localDump sys.path.insert(0, projectRoot) from scanStrings import generateBuilderTemplate # Run builder-specific template generation print("[TOOL] Running string scanner in builder mode...") success = generateBuilderTemplate( mode='limited', # Medium comment setting existingPoFile=None, outputDir=projectLocaleFolder ) if success: # Check if messages.po was generated poFile = os.path.join(projectLocaleFolder, "messages.po") readmeFile = os.path.join(projectLocaleFolder, "README.txt") if os.path.exists(poFile): # Rename messages.po to localeTemplate.po for consistency localeTemplateFile = os.path.join(projectLocaleFolder, "localeTemplate.po") shutil.move(poFile, localeTemplateFile) print(f"[OK] Generated locale template: {localeTemplateFile}") generatedFiles.append(("templates_LOCALE/localeTemplate.po", localeTemplateFile)) else: print("[WARNING] messages.po not found after generation") if os.path.exists(readmeFile): print(f"[OK] Generated instructions: README.txt") generatedFiles.append(("templates_LOCALE/README.txt", readmeFile)) else: print("[ERROR] Failed to generate locale template") except ImportError as e: print(f"[ERROR] Could not import scanStrings: {e}") except Exception as e: print(f"[ERROR] Error generating locale template: {e}") return generatedFiles def copyGeneratedFilesToBuild(generatedFiles, projectRoot, outputDir): """ Copy generated files from project folder to build folder. Args: generatedFiles (list): List of (relativePath, sourceFile) tuples. projectRoot (str): Root directory of the project. outputDir (str): Destination build directory. Returns: None """ if not generatedFiles: return print("\n=== Copying Generated Files to Build ===") for relativePath, srcFile in generatedFiles: # Split path into directory and filename if '/' in relativePath or '\\' in relativePath: pathParts = relativePath.replace('\\', '/').split('/') fileName = pathParts[-1] destDir = outputDir # Create directory structure in build folder for part in pathParts[:-1]: destDir = os.path.join(destDir, part) os.makedirs(destDir, exist_ok=True) destFile = os.path.join(destDir, fileName) else: destFile = os.path.join(outputDir, relativePath) if os.path.exists(srcFile): shutil.copy2(srcFile, destFile) print(f" [FILE] Copied generated file: {relativePath}") else: print(f" [WARNING] Generated file not found: {srcFile}") def main(): """ Main build process orchestration. Returns: None """ # Load build manifest manifest = loadBuildManifest() # Set up paths projectRoot = os.path.dirname(os.path.abspath(__file__)) # Use destination folder from manifest or project root destinationFolder = manifest.get("destinationFolder", "").strip() if destinationFolder and os.path.isabs(destinationFolder): buildsFolder = destinationFolder elif destinationFolder: buildsFolder = os.path.join(projectRoot, destinationFolder) else: buildsFolder = os.path.join(projectRoot, "builds") # Ensure builds folder exists os.makedirs(buildsFolder, exist_ok=True) # Setup build log if configured originalStdout = sys.stdout originalStderr = sys.stderr logFile = None if manifest.get("makeBuildLog", False): timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") logFilePath = os.path.join(buildsFolder, f"BUILD_{timestamp}_LOG.txt") logFile = open(logFilePath, 'w', encoding='utf-8') sys.stdout = Tee(originalStdout, logFile) sys.stderr = Tee(originalStderr, logFile) print(f"[INFO] Build log started: {logFilePath}") try: # Define other paths using manifest resourcesFolderName = manifest.get("resourcesFolder", "resources") metaFolderName = manifest.get("metaFolder", "local") configFolderName = manifest.get("configFolder", "config") resourcesPath = os.path.join(projectRoot, resourcesFolderName) localFolder = os.path.join(projectRoot, metaFolderName) configFolder = os.path.join(projectRoot, configFolderName) # Icon path from manifest iconName = manifest.get("icon", "Icon_big.ico") iconPath = os.path.join(resourcesPath, "sprites", iconName) # Handle locale template if configured - returns list of generated files generatedFiles = handleLocaleTemplate(manifest, projectRoot) # Compile locale files (.po to .mo) - skips default locale (English) if not compileLocaleFiles(projectRoot, resourcesFolderName): print("[ERROR] Build aborted due to locale compilation failure.") exit(1) # Check for missing files (after generating locale template if needed) if not checkMissingFiles(manifest, projectRoot): print("[ERROR] Build aborted due to missing required files.") exit(1) # Update app metadata updateAppMetadata(manifest, localFolder) # Compile resources compiledResourcesPath = compileResources(projectRoot) # Copy default config (creates config.json in project's config folder) copyDefaultConfig(localFolder, configFolder) # Clean previous build cleanPreviousBuild(buildsFolder) # Build executable buildExecutable(manifest, projectRoot, buildsFolder, localFolder, configFolder, iconPath, compiledResourcesPath) # Copy additional folders outputDir = os.path.join(buildsFolder, "BUILD", "Marginal") # Copy compiled locales to build folder (includes default locale .mo file for runtime) if not copyCompiledLocales(projectRoot, resourcesFolderName, outputDir): print("[ERROR] Build aborted due to locale copy failure.") exit(1) # Copy generated files to build folder if generatedFiles: copyGeneratedFilesToBuild(generatedFiles, projectRoot, os.path.join(outputDir, "helpers")) # Copy additional folders (now respecting includeFiles configuration) copyAdditionalFolders(manifest, projectRoot, outputDir) # Calculate elapsed time endTime = time.time() elapsedTime = endTime - startTime # Print summary print("\n" + "=" * 50) print("[OK] BUILD SUCCESSFUL!") print("=" * 50) print(f"[FOLDER] Executable location: {outputDir}") print(f"[RESULT] Main executable: {os.path.join(outputDir, 'Marginal.exe')}") print(f"[TIME] Time elapsed: {elapsedTime:.2f} seconds ({elapsedTime / 60:.2f} minutes)") print("=" * 50) # Show what was baked into the executable print("\n[INFO] Contents baked into executable:") print(f" - {metaFolderName} folder (all files)") if compiledResourcesPath: print(f" - Compiled resources") finally: # Restore stdout/stderr and close log file if logFile: sys.stdout = originalStdout sys.stderr = originalStderr logFile.close() print(f"\n[INFO] Build log saved to: {logFilePath}") if __name__ == "__main__": main()