#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ sync_foundry_module.py Rozszerzony: - Tryb AUTO: wykrywanie katalogów ze zmianami (git), obsługa modułów i niemodułów. - Każdy katalog -> osobny commit i push. - Root version.nfo: opcjonalny bump globalnej wersji (0.x) jeśli są zmiany i HEAD ma tag z obecną wersją. - Zipowanie po commit/push, przed podmianą plików (moduły) lub tuż po commicie (niemoduły): ZIP_MODE = "ALL" | "MODULE_ONLY" | "OFF"; CLI: --zip-mode; --zip-output-dir. Dwa tryby: - AUTO (domyślny): wykryj wszystkie ZMIENIONE podkatalogi w SOURCE_BASE_DIR: * jeśli folder zawiera module.json -> procedura "moduł" * jeśli nie -> procedura "niemoduł" (version.nfo 0.x, inkrementacja) - SINGLE: przetwórz wyłącznie katalog wskazany w MODULE_SUBDIR_NAME (jak wcześniej) Dry-run: - nic NIE modyfikuje (ani plików, ani repo), nie czyści i nie kopiuje – tylko plan. Wymagania: - Python 3.8+ - git w PATH (dla commit/push i wykrywania zmian) - uprawnienia do zapisu w repo i w katalogu docelowym modułów """ from __future__ import annotations import os import sys import shutil import argparse import json import subprocess from pathlib import Path from typing import List, Optional, Tuple, Set # ========================== # KONFIGURACJA (DOMYŚLNA) # ========================== # Tryb: "auto" (wykryj zmienione katalogi) lub "single" (tylko MODULE_SUBDIR_NAME) MODE: str = "auto" # "auto" | "single" TARGET_USER: str = "mtusz" # nazwa folderu w C:\Users\... APPDATA_KIND: Optional[str] = None # None | "Local" | "LocalLow" | "Roaming" TARGET_MODULES_DIR_OVERRIDE: Optional[str] = None # pełna ścieżka do ...\FoundryVTT\Data\modules MODULE_SUBDIR_NAME: str = "wg-voidships" # używane w trybie SINGLE CONFIRM_DELETE: bool = True SOURCE_BASE_DIR: str = r"C:\vtt_work" RELATIVE_PATTERN: str = r"FoundryVTT\Data\modules" DRY_RUN: bool = False # globalny tryb symulacji (nic nie zmienia) # --- ZIP settings --- # ALL -> zipuj WSZYSTKIE katalogi (moduły i niemoduły) # MODULE_ONLY -> zipuj TYLKO katalogi z module.json # OFF -> nie twórz zipów ZIP_MODE: str = "MODULE_ONLY" # "ALL" | "MODULE_ONLY" | "OFF" # Katalog wyjściowy na archiwa .zip; jeśli None, użyje SOURCE_BASE_DIR/_zips ZIP_OUTPUT_DIR: Optional[str] = None # ========================== # KONIEC KONFIGURACJI # ========================== APPDATA_VARIANTS = ("Local", "LocalLow", "Roaming") ARGS = None # wypełniane w main() class AmbiguityError(RuntimeError): pass def norm(p: Path) -> Path: return Path(os.path.normpath(str(p))) def sh(cmd: List[str], cwd: Path) -> Tuple[int, str]: """Uruchom polecenie i zwróć (rc, out).""" r = subprocess.run(cmd, cwd=str(cwd), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) return r.returncode, r.stdout def git_head_tag(repo_dir: Path) -> Optional[str]: """Zwraca tag dokładnie na HEAD (jeśli istnieje), inaczej None.""" rc, out = sh(["git", "describe", "--tags", "--exact-match", "HEAD"], repo_dir) if rc != 0: return None return out.strip() if out.strip() else None def ensure_git_repo(path: Path) -> None: rc, out = sh(["git", "rev-parse", "--is-inside-work-tree"], path) if rc != 0 or "true" not in out.strip().lower(): raise RuntimeError(f"Katalog {norm(path)} nie wygląda na repozytorium git.\n{out}") def git_changed_top_level_dirs(repo_dir: Path) -> List[str]: """ Zwraca posortowaną listę unikalnych NAZW PODKATALOGÓW (1. poziom) w repo, które mają zmiany wg `git status --porcelain` (staged/unstaged/untracked). Zmiany w plikach w root repo są ignorowane (brak katalogu). """ rc, out = sh(["git", "status", "--porcelain"], repo_dir) if rc != 0: raise RuntimeError(f"Błąd podczas `git status --porcelain` w {norm(repo_dir)}:\n{out}") changed: Set[str] = set() for line in out.splitlines(): if not line.strip(): continue # format: XY lub "?? " try: path = line[3:].strip() except Exception: continue # ignoruj rename "R a -> b": wówczas path ma "a -> b" if "->" in path: path = path.split("->", 1)[1].strip() parts = Path(path).parts if len(parts) >= 1: top = parts[0] # bierz tylko katalogi (jeśli top to plik w root, pomiń) if (repo_dir / top).is_dir(): changed.add(top) return sorted(changed) def find_modules_dir_for_user(user: str, relative_pattern: str, appdata_kind: Optional[str]) -> Path: base = Path(f"C:/Users/{user}/AppData") if not base.exists(): raise FileNotFoundError(f"Nie znaleziono katalogu użytkownika: {norm(base)}") candidates: List[Path] = [] if appdata_kind: if appdata_kind not in APPDATA_VARIANTS: raise ValueError(f"APPDATA_KIND musi być jednym z: {APPDATA_VARIANTS}, a nie '{appdata_kind}'") p = base / appdata_kind / Path(relative_pattern) if p.is_dir(): return norm(p) else: raise FileNotFoundError( f"Nie znaleziono katalogu: {norm(p)} (dla APPDATA_KIND={appdata_kind})." ) for kind in APPDATA_VARIANTS: p = base / kind / Path(relative_pattern) if p.is_dir(): candidates.append(p) if not candidates: raise FileNotFoundError( "Nie znaleziono żadnego katalogu zgodnego z wzorcem.\n" f"Sprawdź, czy istnieje któryś z poniższych:\n" + "\n".join(f" - {norm(base / kind / Path(relative_pattern))}" for kind in APPDATA_VARIANTS) ) if len(candidates) == 1: return norm(candidates[0]) suggestions = [] for p in candidates: try: kind = p.parts[p.parts.index("AppData") + 1] except Exception: kind = "??" suggestions.append(f'* Ustaw APPDATA_KIND="{kind}" # trafi w: {norm(p)}') suggestions.append(f'* Albo ustaw TARGET_MODULES_DIR_OVERRIDE="{norm(candidates[0])}" # pełny override') raise AmbiguityError( "Znaleziono wiele pasujących lokalizacji dla wzorca 'FoundryVTT\\Data\\modules':\n" + "\n".join(f" - {norm(c)}" for c in candidates) + "\n\nPropozycje ujednolicenia:\n" + "\n".join(suggestions) ) def ensure_dir(path: Path) -> None: path.mkdir(parents=True, exist_ok=True) def iter_dir_contents(dir_path: Path) -> List[Path]: if not dir_path.exists(): return [] return list(dir_path.iterdir()) def clear_dir_contents(dir_path: Path, dry_run: bool) -> Tuple[int, int]: ensure_dir(dir_path) files = 0 dirs = 0 to_delete = iter_dir_contents(dir_path) if dry_run: print("[DRY-RUN] Zawartość do skasowania:") if not to_delete: print(" (pusto)") for child in to_delete: tag = "[plik]" if child.is_file() else "[katalog]" if child.is_dir() else "[inne]" print(f" {tag} {norm(child)}") for child in to_delete: if child.is_file() or child.is_symlink(): files += 1 elif child.is_dir(): dirs += 1 return files, dirs for child in to_delete: if child.is_file() or child.is_symlink(): files += 1 child.unlink(missing_ok=True) elif child.is_dir(): dirs += 1 shutil.rmtree(child, ignore_errors=True) return files, dirs def plan_copy(src: Path, dst: Path) -> List[Tuple[Path, Path, str]]: plan: List[Tuple[Path, Path, str]] = [] if not src.is_dir(): raise FileNotFoundError(f"Katalog źródłowy nie istnieje: {norm(src)}") for root, dirs, files in os.walk(src): root_p = Path(root) rel_root = root_p.relative_to(src) for d in dirs: s = root_p / d t = dst / rel_root / d status = "overwrite" if t.exists() else "create" plan.append((s, t, status)) for f in files: s = root_p / f t = dst / rel_root / f status = "overwrite" if t.exists() else "create" plan.append((s, t, status)) return plan def copy_dir_contents(src: Path, dst: Path, dry_run: bool) -> Tuple[int, int]: ensure_dir(dst) plan = plan_copy(src, dst) if dry_run: print("[DRY-RUN] Plan kopiowania:") if not plan: print(" (brak plików do skopiowania)") for s, t, status in plan: kind = "[katalog]" if s.is_dir() else "[plik]" print(f" {kind} {norm(s)} -> {norm(t)} ({status})") files = sum(1 for s, _, _ in plan if s.is_file()) dirs = sum(1 for s, _, _ in plan if s.is_dir()) return files, dirs files = 0 dirs = 0 for s, t, _ in plan: if s.is_dir(): shutil.copytree(s, t, dirs_exist_ok=True) dirs += 1 elif s.is_file(): ensure_dir(t.parent) shutil.copy2(s, t) files += 1 elif s.is_symlink(): target = s.resolve() ensure_dir(t.parent) if target.is_dir(): shutil.copytree(target, t, dirs_exist_ok=True) dirs += 1 else: shutil.copy2(target, t) files += 1 else: print(f"[OSTRZEŻENIE] Pominięto nieobsługiwany typ pliku: {norm(s)}") return files, dirs # ============ PRE-FLIGHT: module.json + git ============ def read_manifest(manifest_path: Path) -> dict: if not manifest_path.is_file(): raise FileNotFoundError(f"Brak pliku manifestu: {norm(manifest_path)}") try: with manifest_path.open("r", encoding="utf-8") as f: return json.load(f) except json.JSONDecodeError as e: raise ValueError(f"Niepoprawny JSON w {norm(manifest_path)}: {e}") from e def bump_version(last_version: str) -> str: parts = last_version.strip().split(".") if not parts: raise ValueError(f"Niepoprawna wersja: '{last_version}'") try: n = int(parts[-1]) except ValueError: raise ValueError(f"Ostatni segment wersji nie jest liczbą: '{last_version}'") parts[-1] = str(n + 1) return ".".join(parts) def write_json_atomic(path: Path, data: dict) -> None: tmp = path.with_suffix(path.suffix + ".tmp") with tmp.open("w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) f.write("\n") tmp.replace(path) def run_git_commands(repo_dir: Path, commit_msg: str, dry_run: bool) -> None: if dry_run: print("[DRY-RUN] git add -A") print(f"[DRY-RUN] git commit -m {commit_msg!r}") print("[DRY-RUN] git push") return def run(cmd: List[str]) -> None: rc, out = sh(cmd, repo_dir) if rc != 0: raise RuntimeError(f"Błąd podczas uruchamiania: {' '.join(cmd)}\nWyjście:\n{out}") if out.strip(): print(out) run(["git", "add", "-A"]) rc, out = sh(["git", "commit", "-m", commit_msg], repo_dir) if rc != 0: raise RuntimeError(f"Błąd podczas git commit:\n{out}") if out.strip(): print(out) run(["git", "push"]) def preflight_module(src_dir: Path, dry_run: bool) -> Tuple[str, str]: """ Moduł: odczyt module.json, bump, zapis (jeśli nie dry_run), commit i push. Zwraca (module_id, new_version). """ manifest_path = src_dir / "module.json" manifest = read_manifest(manifest_path) if "id" not in manifest or "version" not in manifest: raise KeyError(f"W {norm(manifest_path)} brakuje pola 'id' lub 'version'.") module_id = str(manifest["id"]) old_version = str(manifest["version"]) new_version = bump_version(old_version) print(f"[PRE][MODULE] {src_dir.name}: id={module_id}, version={old_version} -> {new_version}") if dry_run: print("[DRY-RUN] Nie zapisuję module.json i nie wykonuję git.") else: manifest["version"] = new_version write_json_atomic(manifest_path, manifest) commit_msg = f"sync: {module_id} v{new_version}" run_git_commands(src_dir, commit_msg, dry_run=False) return module_id, new_version def preflight_non_module(src_dir: Path, dry_run: bool) -> str: """ Niemoduł: version.nfo w formacie '0.x' (x int). - jeśli brak -> '0.1' - jeśli jest -> inkrementuj x Zapis (nie dry-run) oraz osobny commit/push. Zwraca new_version (np. '0.4'). """ nfo = src_dir / "version.nfo" if not nfo.exists(): new_version = "0.1" print(f"[PRE][FOLDER] {src_dir.name}: version.nfo brak -> {new_version}") if not dry_run: with nfo.open("w", encoding="utf-8") as f: f.write(new_version + "\n") commit_msg = f"sync: {src_dir.name} v{new_version}" run_git_commands(src_dir, commit_msg, dry_run=False) return new_version # istnieje -> wczytaj i bumpnij content = nfo.read_text(encoding="utf-8").strip() if not content.startswith("0."): raise ValueError(f"{norm(nfo)}: oczekiwano formatu '0.x', otrzymano '{content}'") tail = content.split(".", 1)[1] try: x = int(tail) except ValueError: raise ValueError(f"{norm(nfo)}: część po '0.' nie jest liczbą: '{tail}'") new_version = f"0.{x+1}" print(f"[PRE][FOLDER] {src_dir.name}: version {content} -> {new_version}") if not dry_run: with nfo.open("w", encoding="utf-8") as f: f.write(new_version + "\n") commit_msg = f"sync: {src_dir.name} v{new_version}" run_git_commands(src_dir, commit_msg, dry_run=False) return new_version # ====== ROOT version.nfo (globalna wersja repo) ====== def maybe_bump_root_version(repo_dir: Path, changed_any: bool, dry_run: bool) -> Optional[str]: """ Jeśli: - istnieją zmiany do przetworzenia (changed_any == True) - HEAD ma tag zawierający aktualną wersję globalną z version.nfo (0.x) to bumpnij version.nfo w roocie (0.x -> 0.(x+1)), zrób osobny commit i push. Zwraca nową wersję (str) lub None, jeśli pominięto. """ if not changed_any: print("[ROOT] Pomijam bump wersji globalnej: brak zmian do przetworzenia.") return None nfo = repo_dir / "version.nfo" if not nfo.exists(): print("[ROOT] Brak version.nfo w roocie; pomijam bump (wymagany istniejący plik).") return None current = nfo.read_text(encoding="utf-8").strip() if not current.startswith("0."): raise ValueError(f"{nfo}: oczekiwano formatu '0.x', otrzymano '{current}'") # HEAD musi mieć tag zawierający 'current' tag = git_head_tag(repo_dir) if not tag or current not in tag: print(f"[ROOT] Pomijam bump: {'brak taga na HEAD' if not tag else f'tag \"{tag}\" nie zawiera wersji {current}'}") return None # Bump try: tail = int(current.split(".", 1)[1]) except ValueError: raise ValueError(f"{nfo}: część po '0.' nie jest liczbą: '{current}'") new_version = f"0.{tail+1}" print(f"[ROOT] Globalna wersja: {current} -> {new_version}") if dry_run: print("[DRY-RUN][ROOT] Zapis version.nfo i commit/push zostaną pominięte.") return new_version nfo.write_text(new_version + "\n", encoding="utf-8") run_git_commands(repo_dir, f"sync: root v{new_version}", dry_run=False) return new_version # ====== ZIP utilities ====== def should_zip(is_module: bool, zip_mode: str) -> bool: if zip_mode == "OFF": return False if zip_mode == "ALL": return True # MODULE_ONLY return is_module def ensure_zip_output_dir(source_base: Path, override: Optional[str]) -> Path: out = Path(override) if override else (source_base / "_zips") out.mkdir(parents=True, exist_ok=True) return out def make_zip_for_folder(src_dir: Path, version: str, out_dir: Path, dry_run: bool) -> Path: """ Tworzy .zip z zawartością src_dir (bez nadkatalogów) o nazwie NAME-vVERSION.zip Zwraca ścieżkę do archiwum. """ base_name = f"{src_dir.name}-v{version}" target = out_dir / (base_name + ".zip") if dry_run: print(f"[DRY-RUN][ZIP] {src_dir} -> {target}") return target # shutil.make_archive potrzebuje base_name BEZ .zip i root_dir+base_dir base_name_path = out_dir / base_name shutil.make_archive(str(base_name_path), "zip", root_dir=str(src_dir), base_dir=".") print(f"[ZIP] Utworzono {target}") return target def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(description="Synchronizacja FoundryVTT: AUTO wykrywanie zmian + obsługa modułów/niemodułów.") p.add_argument("--mode", choices=["auto", "single"], default=MODE, help=f"Tryb działania (domyślnie: {MODE}).") p.add_argument("--user", dest="user", default=TARGET_USER, help=f"Nazwa użytkownika Windows (domyślnie: {TARGET_USER})") p.add_argument("--appdata-kind", dest="appdata_kind", choices=APPDATA_VARIANTS + (None,), default=APPDATA_KIND, help=f"Wymuś AppData wariant: {APPDATA_VARIANTS} lub brak (None). Domyślnie: {APPDATA_KIND}") p.add_argument("--modules-dir-override", dest="modules_dir_override", default=TARGET_MODULES_DIR_OVERRIDE, help="Pełna ścieżka do ...\\FoundryVTT\\Data\\modules (pomija wyszukiwanie).") p.add_argument("--module-subdir-name", dest="module_subdir_name", default=MODULE_SUBDIR_NAME, help=f"Nazwa podkatalogu modułu (tylko w --mode single). Domyślnie: {MODULE_SUBDIR_NAME}") cd_group = p.add_mutually_exclusive_group() cd_group.add_argument("--confirm-delete", dest="confirm_delete", action="store_true", help="Włącz potwierdzenie kasowania.") cd_group.add_argument("--no-confirm-delete", dest="confirm_delete", action="store_false", help="Wyłącz potwierdzenie kasowania.") p.set_defaults(confirm_delete=CONFIRM_DELETE) p.add_argument("--source-base-dir", dest="source_base_dir", default=SOURCE_BASE_DIR, help=f"Katalog źródłowy repo (domyślnie: {SOURCE_BASE_DIR})") p.add_argument("--relative-pattern", dest="relative_pattern", default=RELATIVE_PATTERN, help=f"Wzorzec relatywny od AppData dla katalogu modules (domyślnie: {RELATIVE_PATTERN})") dr_group = p.add_mutually_exclusive_group() dr_group.add_argument("--dry-run", dest="dry_run", action="store_true", help="Tryb symulacji: tylko plan (bez modyfikacji i bez gita).") dr_group.add_argument("--no-dry-run", dest="dry_run", action="store_false", help="Tryb wykonawczy: modyfikacje + git.") p.set_defaults(dry_run=DRY_RUN) # ZIP options p.add_argument("--zip-mode", dest="zip_mode", choices=["ALL", "MODULE_ONLY", "OFF"], default=ZIP_MODE, help=f"Zipowanie: ALL | MODULE_ONLY | OFF (domyślnie: {ZIP_MODE})") p.add_argument("--zip-output-dir", dest="zip_output_dir", default=ZIP_OUTPUT_DIR, help="Katalog wyjściowy na zipy (domyślnie: SOURCE_BASE_DIR/_zips)") return p.parse_args() def resolve_modules_base(user: str, relative_pattern: str, appdata_kind: Optional[str], override: Optional[str]) -> Path: if override: modules_dir = Path(override) if not modules_dir.is_dir(): raise FileNotFoundError(f"TARGET_MODULES_DIR_OVERRIDE wskazuje na nieistniejący katalog: {norm(modules_dir)}") return modules_dir return find_modules_dir_for_user(user, relative_pattern, appdata_kind) def process_module(src_dir: Path, modules_base: Path, confirm_delete: bool, dry_run: bool) -> None: module_id, new_ver = preflight_module(src_dir, dry_run=dry_run) target_dir = modules_base / src_dir.name print(f"[INFO][MODULE] Cel modułu: {norm(target_dir)}") if confirm_delete and not dry_run: ans = input(f"Czy na pewno wyczyścić zawartość '{norm(target_dir)}'? [y/N]: ").strip().lower() if ans not in ("y", "yes", "t", "tak"): raise RuntimeError("Przerwano na życzenie użytkownika.") if confirm_delete and dry_run: print("[INFO] (dry-run) Pytanie o potwierdzenie pominięte.") # ZIP po commicie, przed podmianą if should_zip(is_module=True, zip_mode=ARGS.zip_mode): out_dir = ensure_zip_output_dir(Path(ARGS.source_base_dir), ARGS.zip_output_dir) try: make_zip_for_folder(src_dir, new_ver, out_dir, dry_run) except Exception as e: raise RuntimeError(f"Błąd zipowania modułu {src_dir.name}: {e}") from e print("[AKCJA] Czyszczenie..." if not dry_run else "[SYMULACJA] Plan czyszczenia...") del_files, del_dirs = clear_dir_contents(target_dir, dry_run=dry_run) print(f"[INFO] {'Do usunięcia' if dry_run else 'Usunięto'}: plików={del_files}, katalogów={del_dirs}") print("[AKCJA] Kopiowanie..." if not dry_run else "[SYMULACJA] Plan kopiowania...") cp_files, cp_dirs = copy_dir_contents(src_dir, target_dir, dry_run=dry_run) print(f"[INFO] {'Do skopiowania' if dry_run else 'Skopiowano'}: plików={cp_files}, katalogów={cp_dirs}") print(f"[DONE][MODULE] {src_dir.name} -> commit 'sync: {module_id} v{new_ver}' wykonany." if not dry_run else f"[DRY-RUN][MODULE] {src_dir.name} -> commit planowany.") def process_non_module(src_dir: Path, dry_run: bool) -> None: new_ver = preflight_non_module(src_dir, dry_run=dry_run) # ZIP po commicie (jeśli ustawienie pozwala) if should_zip(is_module=False, zip_mode=ARGS.zip_mode): out_dir = ensure_zip_output_dir(Path(ARGS.source_base_dir), ARGS.zip_output_dir) try: make_zip_for_folder(src_dir, new_ver, out_dir, dry_run) except Exception as e: raise RuntimeError(f"Błąd zipowania folderu {src_dir.name}: {e}") from e print(f"[DONE][FOLDER] {src_dir.name} -> version.nfo = {new_ver}; commit wykonany." if not dry_run else f"[DRY-RUN][FOLDER] {src_dir.name} -> version.nfo będzie {new_ver}; commit planowany.") def main() -> int: global ARGS ARGS = parse_args() mode = ARGS.mode user = ARGS.user appdata_kind = ARGS.appdata_kind if ARGS.appdata_kind != "None" else None modules_override = ARGS.modules_dir_override module_subdir_single = ARGS.module_subdir_name confirm_delete = ARGS.confirm_delete source_base = Path(ARGS.source_base_dir) relative_pattern = ARGS.relative_pattern dry_run = ARGS.dry_run if not source_base.is_dir(): raise FileNotFoundError(f"Katalog źródłowy nie istnieje: {norm(source_base)}") ensure_git_repo(source_base) # Ustal bazę modules (tylko raz) modules_base = resolve_modules_base(user, relative_pattern, appdata_kind, modules_override) print(f"[INFO] Katalog modules base: {norm(modules_base)}") if mode == "single": src_dir = source_base / module_subdir_single if not src_dir.is_dir(): raise FileNotFoundError(f"[SINGLE] Brak katalogu: {norm(src_dir)}") # Root bump (globalna wersja) – tylko jeśli "są zmiany do przetworzenia" (single -> True) maybe_bump_root_version(source_base, changed_any=True, dry_run=dry_run) if (src_dir / "module.json").is_file(): print(f"[MODE=SINGLE][MODULE] {src_dir.name}") process_module(src_dir, modules_base, confirm_delete, dry_run) else: print(f"[MODE=SINGLE][FOLDER] {src_dir.name}") process_non_module(src_dir, dry_run) print("[SUKCES] Tryb SINGLE zakończony.") return 0 # AUTO: wykryj zmienione katalogi changed = git_changed_top_level_dirs(source_base) print(f"[AUTO] Zmienione katalogi w {norm(source_base)}: {changed if changed else '(brak)'}") # Root bump (globalna wersja) – tylko jeśli są jakiekolwiek zmiany do przetworzenia maybe_bump_root_version(source_base, changed_any=bool(changed), dry_run=dry_run) if not changed: print("[INFO] Brak zmian do przetworzenia.") return 0 # Przetwarzaj w stałej kolejności for name in changed: src_dir = source_base / name if (src_dir / "module.json").is_file(): print(f"\n[AUTO][MODULE] {name}") process_module(src_dir, modules_base, confirm_delete, dry_run) else: print(f"\n[AUTO][FOLDER] {name}") process_non_module(src_dir, dry_run) print("\n[SUKCES] Tryb AUTO zakończony.") return 0 if __name__ == "__main__": try: sys.exit(main()) except AmbiguityError as e: print("\n[NIERÓWNOZNACZNOŚĆ]\n" + str(e), file=sys.stderr) sys.exit(2) except Exception as e: print("\n[BŁĄD]\n" + str(e), file=sys.stderr) sys.exit(1)