|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""deploy |
| 3 | +
|
| 4 | +Copy freshly built mod jars from a build output folder into the matching |
| 5 | +PrismLauncher (MultiMC layout) instances. |
| 6 | +
|
| 7 | +If that file does not exist it is created with sensible defaults (matching this |
| 8 | +project) and the script exits so you can tweak it first. |
| 9 | +
|
| 10 | +Usage: |
| 11 | + python3 ./deploy.py # deploy everything |
| 12 | + python3 ./deploy.py --dry-run # show what would happen |
| 13 | + python3 ./deploy.py --only 26.3-fabric |
| 14 | + python3 ./deploy.py --list |
| 15 | + python3 ./deploy.py --config other.json |
| 16 | +""" |
| 17 | + |
| 18 | +from __future__ import annotations |
| 19 | + |
| 20 | +import argparse |
| 21 | +import fnmatch |
| 22 | +import json |
| 23 | +import shutil |
| 24 | +import sys |
| 25 | +from pathlib import Path |
| 26 | + |
| 27 | +SCRIPT = Path(__file__).resolve() |
| 28 | +SCRIPT_DIR = SCRIPT.parent |
| 29 | +DEFAULT_CONFIG = SCRIPT.with_suffix(".json") |
| 30 | + |
| 31 | +DEFAULT_CONFIG_DATA = { |
| 32 | + # Base folder. Relative paths elsewhere resolve against this. |
| 33 | + "project_dir": ".", |
| 34 | + # Where the built jars live, relative to project_dir. |
| 35 | + "source_dir": "build/libs", |
| 36 | + # PrismLauncher instances folder. |
| 37 | + "instances_dir": "~/Library/Application Support/PrismLauncher/instances", |
| 38 | + # Layout inside an instance folder. |
| 39 | + "instance_game_subdir": "minecraft", |
| 40 | + "mods_subdir": "mods", |
| 41 | + # Which jar files inside each source folder are candidates... |
| 42 | + "include": ["*.jar"], |
| 43 | + # ...and which of those to ignore (sources / intermediate jars). |
| 44 | + "exclude": ["*-sources.jar", "*-dev-shadow.jar"], |
| 45 | + # Delete already-installed files matching these patterns before copying. |
| 46 | + "clean": ["customcursor-*"], |
| 47 | + # src = folder name in source_dir, instance = folder name in instances_dir. |
| 48 | + "mappings": [ |
| 49 | + {"src": "26.1-fabric", "instance": "26.1.2(1)"}, |
| 50 | + {"src": "26.1-neoforge", "instance": "26.1.2"}, |
| 51 | + {"src": "26.2-fabric", "instance": "26.2(1)"}, |
| 52 | + {"src": "26.2-neoforge", "instance": "26.2"}, |
| 53 | + {"src": "26.3-fabric", "instance": "26.3"}, |
| 54 | + {"src": "26.3-neoforge", "instance": "26.3(1)"}, |
| 55 | + ], |
| 56 | +} |
| 57 | + |
| 58 | + |
| 59 | +def load_config(path: Path) -> dict: |
| 60 | + if not path.exists(): |
| 61 | + path.write_text(json.dumps(DEFAULT_CONFIG_DATA, indent=2) + "\n") |
| 62 | + print(f"Created default config: {path}") |
| 63 | + print("Edit it, then run the script again.") |
| 64 | + sys.exit(0) |
| 65 | + |
| 66 | + try: |
| 67 | + data = json.loads(path.read_text()) |
| 68 | + except json.JSONDecodeError as exc: |
| 69 | + sys.exit(f"Invalid JSON in {path}: {exc}") |
| 70 | + |
| 71 | + # Merge so a partial config still works. |
| 72 | + config = dict(DEFAULT_CONFIG_DATA) |
| 73 | + config.update(data) |
| 74 | + return config |
| 75 | + |
| 76 | + |
| 77 | +def matches_any(name: str, patterns: list[str]) -> bool: |
| 78 | + return any(fnmatch.fnmatch(name, p) for p in patterns) |
| 79 | + |
| 80 | + |
| 81 | +def resolve_jar(directory: Path, include: list[str], exclude: list[str], |
| 82 | + per_map_exclude: list[str]) -> list[Path]: |
| 83 | + if not directory.is_dir(): |
| 84 | + return [] |
| 85 | + |
| 86 | + jars: list[Path] = [] |
| 87 | + for entry in sorted(directory.iterdir()): |
| 88 | + if not entry.is_file(): |
| 89 | + if matches_any(entry.name, include): |
| 90 | + print(f" ! skipping non-file (extracted jar?): {entry.name}") |
| 91 | + continue |
| 92 | + if not matches_any(entry.name, include): |
| 93 | + continue |
| 94 | + if matches_any(entry.name, exclude) or matches_any(entry.name, per_map_exclude): |
| 95 | + continue |
| 96 | + jars.append(entry) |
| 97 | + return jars |
| 98 | + |
| 99 | + |
| 100 | +def main() -> int: |
| 101 | + parser = argparse.ArgumentParser( |
| 102 | + description="Deploy built mod jars into PrismLauncher instances.", |
| 103 | + ) |
| 104 | + parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG, |
| 105 | + help=f"config file (default: {DEFAULT_CONFIG.name})") |
| 106 | + parser.add_argument("--dry-run", action="store_true", |
| 107 | + help="print actions without touching anything") |
| 108 | + parser.add_argument("--only", action="append", metavar="SRC", |
| 109 | + help="only deploy this src folder (repeatable)") |
| 110 | + parser.add_argument("--list", action="store_true", |
| 111 | + help="list mappings and resolved jars, then exit") |
| 112 | + args = parser.parse_args() |
| 113 | + |
| 114 | + config = load_config(args.config.resolve()) |
| 115 | + |
| 116 | + config_dir = args.config.resolve().parent |
| 117 | + project_dir = (config_dir / config["project_dir"]).resolve() |
| 118 | + source_dir = (project_dir / config["source_dir"]).resolve() |
| 119 | + instances_dir = Path(config["instances_dir"]).expanduser().resolve() |
| 120 | + game_sub = config["instance_game_subdir"] |
| 121 | + mods_sub = config["mods_subdir"] |
| 122 | + include = config["include"] |
| 123 | + exclude = config["exclude"] |
| 124 | + clean = config["clean"] |
| 125 | + |
| 126 | + if not instances_dir.is_dir(): |
| 127 | + sys.exit(f"Instances folder not found: {instances_dir}") |
| 128 | + |
| 129 | + mappings = config["mappings"] |
| 130 | + if args.only: |
| 131 | + wanted = set(args.only) |
| 132 | + mappings = [m for m in mappings if m["src"] in wanted] |
| 133 | + missing = wanted - {m["src"] for m in mappings} |
| 134 | + if missing: |
| 135 | + print(f"warning: no mapping for {', '.join(sorted(missing))}") |
| 136 | + |
| 137 | + print(f"source: {source_dir}") |
| 138 | + print(f"instances: {instances_dir}") |
| 139 | + print() |
| 140 | + |
| 141 | + deployed = 0 |
| 142 | + for mapping in mappings: |
| 143 | + src_name = mapping["src"] |
| 144 | + instance_name = mapping["instance"] |
| 145 | + src_path = source_dir / src_name |
| 146 | + mods_path = instances_dir / instance_name / game_sub / mods_sub |
| 147 | + per_map_exclude = mapping.get("exclude", []) |
| 148 | + |
| 149 | + print(f"[{src_name}] -> {instance_name}") |
| 150 | + |
| 151 | + jars = resolve_jar(src_path, include, exclude, per_map_exclude) |
| 152 | + if not jars: |
| 153 | + print(f" ! no deployable jar in {src_path}") |
| 154 | + continue |
| 155 | + |
| 156 | + if not mods_path.is_dir(): |
| 157 | + if args.dry_run: |
| 158 | + print(f" would create {mods_path}") |
| 159 | + else: |
| 160 | + mods_path.mkdir(parents=True, exist_ok=True) |
| 161 | + print(f" created {mods_path}") |
| 162 | + |
| 163 | + # Old installs that would be cleaned out. |
| 164 | + stale: list[Path] = [] |
| 165 | + if mods_path.is_dir(): |
| 166 | + stale = [p for p in sorted(mods_path.iterdir()) |
| 167 | + if p.is_file() and matches_any(p.name, clean)] |
| 168 | + |
| 169 | + for existing in stale: |
| 170 | + print(f" rm {existing.name}") |
| 171 | + for jar in jars: |
| 172 | + print(f" copy {jar.name}") |
| 173 | + |
| 174 | + if args.list or args.dry_run: |
| 175 | + continue |
| 176 | + |
| 177 | + for existing in stale: |
| 178 | + existing.unlink() |
| 179 | + |
| 180 | + for jar in jars: |
| 181 | + shutil.copy2(jar, mods_path / jar.name) |
| 182 | + deployed += 1 |
| 183 | + |
| 184 | + print() |
| 185 | + if args.list: |
| 186 | + print("(list only, nothing copied)") |
| 187 | + elif args.dry_run: |
| 188 | + print("dry run, nothing copied") |
| 189 | + else: |
| 190 | + print(f"deployed {deployed} jar(s)") |
| 191 | + return 0 |
| 192 | + |
| 193 | + |
| 194 | +if __name__ == "__main__": |
| 195 | + raise SystemExit(main()) |
0 commit comments