modkeel 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- modkeel/__init__.py +26 -0
- modkeel/build.py +496 -0
- modkeel/buildinfo.py +236 -0
- modkeel/cli.py +59 -0
- modkeel/commands/__init__.py +5 -0
- modkeel/commands/_shared.py +158 -0
- modkeel/commands/compile.py +290 -0
- modkeel/commands/get.py +227 -0
- modkeel/commands/recommend.py +264 -0
- modkeel/commands/search.py +219 -0
- modkeel/commands/status.py +108 -0
- modkeel/commands/token.py +56 -0
- modkeel/config.py +127 -0
- modkeel/constants.py +19 -0
- modkeel/crowdsource.py +205 -0
- modkeel/docker.py +883 -0
- modkeel/evidence.py +297 -0
- modkeel/github.py +811 -0
- modkeel/javascan.py +187 -0
- modkeel/linkage.py +458 -0
- modkeel/loaders.py +242 -0
- modkeel/mappings.py +394 -0
- modkeel/memberlink.py +410 -0
- modkeel/mixinscan.py +536 -0
- modkeel/models.py +271 -0
- modkeel/modrinth.py +566 -0
- modkeel/pipeline.py +1422 -0
- modkeel/prebuild.py +648 -0
- modkeel/recommend.py +346 -0
- modkeel/relax.py +147 -0
- modkeel/resolve.py +293 -0
- modkeel/scanner.py +223 -0
- modkeel/sources.py +598 -0
- modkeel/symbols.py +398 -0
- modkeel/target.py +249 -0
- modkeel/utils.py +231 -0
- modkeel/validation.py +595 -0
- modkeel/version.py +165 -0
- modkeel-0.1.1.dist-info/METADATA +268 -0
- modkeel-0.1.1.dist-info/RECORD +43 -0
- modkeel-0.1.1.dist-info/WHEEL +4 -0
- modkeel-0.1.1.dist-info/entry_points.txt +2 -0
- modkeel-0.1.1.dist-info/licenses/LICENSE +21 -0
modkeel/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Modkeel - Minecraft Mod Auto-Compiler
|
|
3
|
+
|
|
4
|
+
Automatically detects, compiles, and installs mods from GitHub repositories
|
|
5
|
+
for specific Minecraft versions and mod loaders.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from modkeel.constants import MODKEEL_VERSION
|
|
9
|
+
from modkeel.loaders import ( # noqa: F401
|
|
10
|
+
ALL_LOADERS,
|
|
11
|
+
KNOWN_LOADER_VERSIONS,
|
|
12
|
+
LOADER_PROFILES,
|
|
13
|
+
get_bridge_mods,
|
|
14
|
+
get_cross_loader_chain,
|
|
15
|
+
get_docker_server_type,
|
|
16
|
+
get_installer_url,
|
|
17
|
+
get_known_version,
|
|
18
|
+
get_maven_domain,
|
|
19
|
+
get_profile,
|
|
20
|
+
normalize_loader,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from modkeel.recommend import RecommendationEngine # noqa: F401
|
|
24
|
+
from modkeel.scanner import detect_mods_folder, scan_mods_folder # noqa: F401
|
|
25
|
+
|
|
26
|
+
__version__ = MODKEEL_VERSION
|
modkeel/build.py
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""Build and compilation functions for Modkeel."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import zipfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
import toml
|
|
13
|
+
|
|
14
|
+
from modkeel.loaders import LOADER_PROFILES
|
|
15
|
+
from modkeel.models import FailureType
|
|
16
|
+
from modkeel.version import is_version_in_fabric_range, is_version_in_maven_range
|
|
17
|
+
from modkeel.constants import MODKEEL_HOME
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("modkeel")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def classify_build_failure(
|
|
23
|
+
stderr: str, stdout: str
|
|
24
|
+
) -> Tuple[FailureType, List[str]]:
|
|
25
|
+
"""
|
|
26
|
+
Classify a Gradle build failure by parsing stderr/stdout.
|
|
27
|
+
Returns (failure_type, list_of_missing_dependency_coordinates).
|
|
28
|
+
"""
|
|
29
|
+
combined = (stderr or "") + "\n" + (stdout or "")
|
|
30
|
+
missing_deps: List[str] = []
|
|
31
|
+
|
|
32
|
+
dep_patterns = [
|
|
33
|
+
r"Could not find\s+([\w.\-]+:[\w.\-]+:[\w.\-+]+)",
|
|
34
|
+
r"Could not resolve\s+([\w.\-]+:[\w.\-]+:[\w.\-+]+)",
|
|
35
|
+
r"Could not resolve all (?:files|dependencies) for configuration",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
is_dep_failure = False
|
|
39
|
+
for pattern in dep_patterns:
|
|
40
|
+
matches = re.findall(pattern, combined)
|
|
41
|
+
if matches:
|
|
42
|
+
is_dep_failure = True
|
|
43
|
+
if isinstance(matches[0], str) and ":" in matches[0]:
|
|
44
|
+
missing_deps.extend(m.rstrip(".") for m in matches)
|
|
45
|
+
|
|
46
|
+
if is_dep_failure:
|
|
47
|
+
seen = set()
|
|
48
|
+
unique_deps = []
|
|
49
|
+
for dep in missing_deps:
|
|
50
|
+
if dep not in seen:
|
|
51
|
+
seen.add(dep)
|
|
52
|
+
unique_deps.append(dep)
|
|
53
|
+
return FailureType.DEPENDENCY_RESOLUTION, unique_deps
|
|
54
|
+
|
|
55
|
+
return FailureType.BUILD_ERROR, []
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_EXCLUDED_JAR_SUFFIXES = ('-sources.jar', '-dev.jar', '-javadoc.jar', '-slim.jar', '-api.jar')
|
|
59
|
+
_MOD_METADATA = ('META-INF/neoforge.mods.toml', 'META-INF/mods.toml', 'fabric.mod.json',
|
|
60
|
+
'quilt.mod.json')
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _is_mod_jar(jar: Path) -> bool:
|
|
64
|
+
try:
|
|
65
|
+
with zipfile.ZipFile(jar) as z:
|
|
66
|
+
names = set(z.namelist())
|
|
67
|
+
except (zipfile.BadZipFile, OSError):
|
|
68
|
+
return False
|
|
69
|
+
return any(m in names for m in _MOD_METADATA)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def find_output_jars(repo_path: Path, max_depth: int = 3) -> List[Path]:
|
|
73
|
+
"""Built JARs from the root and subprojects (multi-loader builds put them in
|
|
74
|
+
``neoforge/build/libs`` and similar). Jars carrying mod metadata win; when none
|
|
75
|
+
do, fall back to every candidate so single-module builds keep working."""
|
|
76
|
+
candidates = []
|
|
77
|
+
for depth in range(max_depth + 1):
|
|
78
|
+
pattern = "/".join(["*"] * depth + ["build", "libs", "*.jar"])
|
|
79
|
+
for jar in repo_path.glob(pattern):
|
|
80
|
+
rel = jar.relative_to(repo_path).parts
|
|
81
|
+
if rel[0] == "buildSrc" or jar.name.endswith(_EXCLUDED_JAR_SUFFIXES):
|
|
82
|
+
continue
|
|
83
|
+
candidates.append(jar)
|
|
84
|
+
mod_jars = [j for j in candidates if _is_mod_jar(j)]
|
|
85
|
+
return mod_jars or candidates
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# The lines that say why a build failed; Gradle's own tail only says that it did
|
|
89
|
+
_KEY_ERROR = re.compile(r"\berror:|\[ERROR\]|^\s*ERROR\b|Caused by:|cannot find symbol"
|
|
90
|
+
r"|What went wrong|OutOfMemoryError|Not enough memory", re.I)
|
|
91
|
+
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def key_error_lines(output: str, limit: int = 12) -> List[str]:
|
|
95
|
+
"""First distinct error lines of a build's output, colour codes stripped."""
|
|
96
|
+
out: List[str] = []
|
|
97
|
+
lines = [ln.strip() for ln in _ANSI.sub("", output).splitlines()]
|
|
98
|
+
for i, line in enumerate(lines):
|
|
99
|
+
if not line or not _KEY_ERROR.search(line):
|
|
100
|
+
continue
|
|
101
|
+
if line.startswith("* What went wrong"):
|
|
102
|
+
# Gradle puts the cause on the next line
|
|
103
|
+
line = next((ln for ln in lines[i + 1:] if ln), line)
|
|
104
|
+
if line not in out:
|
|
105
|
+
out.append(line[:300])
|
|
106
|
+
if len(out) >= limit:
|
|
107
|
+
break
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
GRADLE_DISTS = MODKEEL_HOME / "gradle"
|
|
112
|
+
_WRAPPER_URL = re.compile(r"^distributionUrl\s*=\s*(\S+)", re.M)
|
|
113
|
+
_LOADER_DIR_ORDER = ("fabric", "neoforge", "forge", "quilt")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _gradle_script(root: Path) -> Path:
|
|
117
|
+
return root / ("gradlew.bat" if os.name == 'nt' else "gradlew")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def ensure_gradle_distribution(url: str) -> Optional[Path]:
|
|
121
|
+
"""Download and unpack a Gradle distribution once; return its gradle executable."""
|
|
122
|
+
import requests # local: nothing else in build.py touches the network
|
|
123
|
+
|
|
124
|
+
name = url.rsplit("/", 1)[-1].removesuffix(".zip")
|
|
125
|
+
home = GRADLE_DISTS / name
|
|
126
|
+
exe_name = "gradle.bat" if os.name == 'nt' else "gradle"
|
|
127
|
+
found = list(home.glob(f"*/bin/{exe_name}")) if home.exists() else []
|
|
128
|
+
if found:
|
|
129
|
+
return found[0]
|
|
130
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
archive = home / "dist.zip"
|
|
132
|
+
try:
|
|
133
|
+
with requests.get(url, stream=True, timeout=300) as r:
|
|
134
|
+
r.raise_for_status()
|
|
135
|
+
with open(archive, "wb") as f:
|
|
136
|
+
for chunk in r.iter_content(1 << 16):
|
|
137
|
+
f.write(chunk)
|
|
138
|
+
with zipfile.ZipFile(archive) as z:
|
|
139
|
+
z.extractall(home)
|
|
140
|
+
except Exception as e: # noqa: BLE001 - the caller reports a missing wrapper
|
|
141
|
+
logger.debug(f"Gradle distribution download failed ({url}): {e}")
|
|
142
|
+
return None
|
|
143
|
+
finally:
|
|
144
|
+
archive.unlink(missing_ok=True)
|
|
145
|
+
found = list(home.glob(f"*/bin/{exe_name}"))
|
|
146
|
+
if found and os.name != 'nt':
|
|
147
|
+
os.chmod(found[0], 0o755)
|
|
148
|
+
return found[0] if found else None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def resolve_gradle(repo_path: Path, loader: Optional[str] = None
|
|
152
|
+
) -> Tuple[Optional[Path], Optional[Path]]:
|
|
153
|
+
"""(build root, gradle executable) for a repo.
|
|
154
|
+
|
|
155
|
+
The wrapper script wins. A repo that ships only gradle-wrapper.properties gets the
|
|
156
|
+
distribution it names. Repos whose loaders are separate Gradle projects in subfolders
|
|
157
|
+
(``Fabric/``, ``NeoForge/``) build the folder of ``loader``, else the first known one.
|
|
158
|
+
"""
|
|
159
|
+
script = _gradle_script(repo_path)
|
|
160
|
+
if script.exists():
|
|
161
|
+
if os.name != 'nt':
|
|
162
|
+
os.chmod(script, 0o755)
|
|
163
|
+
return repo_path, script
|
|
164
|
+
props = repo_path / "gradle" / "wrapper" / "gradle-wrapper.properties"
|
|
165
|
+
has_settings = any((repo_path / f).exists()
|
|
166
|
+
for f in ("settings.gradle", "settings.gradle.kts",
|
|
167
|
+
"build.gradle", "build.gradle.kts"))
|
|
168
|
+
if props.exists() and has_settings:
|
|
169
|
+
m = _WRAPPER_URL.search(props.read_text(encoding="utf-8", errors="replace"))
|
|
170
|
+
if m:
|
|
171
|
+
exe = ensure_gradle_distribution(m.group(1).replace("\\:", ":"))
|
|
172
|
+
if exe:
|
|
173
|
+
return repo_path, exe
|
|
174
|
+
subs = {d.name.lower(): d for d in repo_path.iterdir()
|
|
175
|
+
if d.is_dir() and (_gradle_script(d).exists()
|
|
176
|
+
or (d / "gradle" / "wrapper" / "gradle-wrapper.properties")
|
|
177
|
+
.exists())}
|
|
178
|
+
for name in ([loader.lower()] if loader else []) + list(_LOADER_DIR_ORDER):
|
|
179
|
+
if name in subs:
|
|
180
|
+
return resolve_gradle(subs[name])
|
|
181
|
+
return None, None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def loader_subproject(root: Path, loader: str) -> Optional[str]:
|
|
185
|
+
"""Gradle path of a multi-loader repo's subproject for ``loader`` (``:Fabric``), if any."""
|
|
186
|
+
for d in root.iterdir():
|
|
187
|
+
if d.is_dir() and d.name.lower() == loader.lower() and \
|
|
188
|
+
any((d / f).exists() for f in ("build.gradle", "build.gradle.kts")):
|
|
189
|
+
return f":{d.name}"
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def build_tasks(root: Path, mc_version: Optional[str], loader: Optional[str] = None) -> List[str]:
|
|
194
|
+
"""Tasks to build. Stonecutter builds every version it knows, and a multi-loader
|
|
195
|
+
build fails as a whole when one loader breaks: narrow to the target (and ``loader``)."""
|
|
196
|
+
sub = loader_subproject(root, loader) if loader else None
|
|
197
|
+
if sub:
|
|
198
|
+
return [f"{sub}:build"]
|
|
199
|
+
versions = root / "versions"
|
|
200
|
+
stonecutter = any((root / f).exists()
|
|
201
|
+
for f in ("stonecutter.gradle.kts", "stonecutter.gradle"))
|
|
202
|
+
if not (mc_version and stonecutter and versions.is_dir()):
|
|
203
|
+
return ["build"]
|
|
204
|
+
names = [d.name for d in versions.iterdir()
|
|
205
|
+
if d.is_dir() and (d.name == mc_version or d.name.startswith(mc_version + "-"))]
|
|
206
|
+
return [f":{n}:build" for n in sorted(names)] or ["build"]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def select_main_jar(jar_files: List[Path], mc_version: Optional[str] = None) -> Path:
|
|
210
|
+
"""Fat jar, else the largest. Jars that validate for ``mc_version`` go first, so a
|
|
211
|
+
multi-version build does not hand back another version's jar."""
|
|
212
|
+
if mc_version:
|
|
213
|
+
jar_files = [j for j in jar_files if validate_jar(j, mc_version)[0]] or jar_files
|
|
214
|
+
fat_jars = [j for j in jar_files if j.name.endswith(('-all.jar', '-shadow.jar'))]
|
|
215
|
+
return max(fat_jars or jar_files, key=lambda j: j.stat().st_size)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def compile_mod(
|
|
219
|
+
repo_path: Path,
|
|
220
|
+
extra_gradle_args: Optional[List[str]] = None,
|
|
221
|
+
mc_version: Optional[str] = None,
|
|
222
|
+
loader: Optional[str] = None,
|
|
223
|
+
) -> Tuple[bool, Optional[Path], str, FailureType, List[str]]:
|
|
224
|
+
"""
|
|
225
|
+
Compile the mod using Gradle.
|
|
226
|
+
Returns (success, jar_path, message, failure_type, missing_deps)
|
|
227
|
+
"""
|
|
228
|
+
print(" \U0001f528 Compiling...")
|
|
229
|
+
|
|
230
|
+
root, gradlew = resolve_gradle(repo_path, loader)
|
|
231
|
+
if not gradlew:
|
|
232
|
+
return (False, None, "Gradle wrapper not found",
|
|
233
|
+
FailureType.BUILD_ERROR, [])
|
|
234
|
+
repo_path = root
|
|
235
|
+
|
|
236
|
+
# Quick dependency resolution check
|
|
237
|
+
try:
|
|
238
|
+
print(" \U0001f50d Checking dependencies...")
|
|
239
|
+
dep_cmd = [
|
|
240
|
+
str(gradlew), "dependencies", "--configuration",
|
|
241
|
+
"compileClasspath", "--no-daemon"
|
|
242
|
+
]
|
|
243
|
+
if extra_gradle_args:
|
|
244
|
+
dep_cmd.extend(extra_gradle_args)
|
|
245
|
+
|
|
246
|
+
dep_result = subprocess.run(
|
|
247
|
+
dep_cmd,
|
|
248
|
+
cwd=repo_path,
|
|
249
|
+
capture_output=True,
|
|
250
|
+
text=True,
|
|
251
|
+
timeout=120
|
|
252
|
+
)
|
|
253
|
+
if dep_result.returncode != 0:
|
|
254
|
+
fail_type, missing = classify_build_failure(
|
|
255
|
+
dep_result.stderr, dep_result.stdout
|
|
256
|
+
)
|
|
257
|
+
if fail_type == FailureType.DEPENDENCY_RESOLUTION:
|
|
258
|
+
print(f" \u274c Dependency check failed: {', '.join(missing[:3])}")
|
|
259
|
+
return (False, None,
|
|
260
|
+
f"Dependency check failed: {missing}",
|
|
261
|
+
fail_type, missing)
|
|
262
|
+
print(" \u26a0\ufe0f Dep check returned error but not dep-related, continuing build...")
|
|
263
|
+
except subprocess.TimeoutExpired:
|
|
264
|
+
print(" \u26a0\ufe0f Dep check timed out, continuing with full build...")
|
|
265
|
+
except Exception as e:
|
|
266
|
+
print(f" \u26a0\ufe0f Dep check error ({e}), continuing with full build...")
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
cmd = [str(gradlew), *build_tasks(repo_path, mc_version, loader), "--no-daemon"]
|
|
270
|
+
if extra_gradle_args:
|
|
271
|
+
cmd.extend(extra_gradle_args)
|
|
272
|
+
|
|
273
|
+
result = subprocess.run(
|
|
274
|
+
cmd,
|
|
275
|
+
cwd=repo_path,
|
|
276
|
+
capture_output=True,
|
|
277
|
+
text=True,
|
|
278
|
+
timeout=600
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
if result.returncode != 0:
|
|
282
|
+
failure_type, missing_deps = classify_build_failure(
|
|
283
|
+
result.stderr, result.stdout
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
stderr_tail = '\n'.join(
|
|
287
|
+
result.stderr.split('\n')[-15:]
|
|
288
|
+
) if result.stderr else ''
|
|
289
|
+
stdout_tail = '\n'.join(
|
|
290
|
+
result.stdout.split('\n')[-15:]
|
|
291
|
+
) if result.stdout else ''
|
|
292
|
+
error_detail = f"Gradle build failed (exit code {result.returncode}):\n"
|
|
293
|
+
causes = key_error_lines(result.stdout + "\n" + result.stderr)
|
|
294
|
+
if causes:
|
|
295
|
+
error_detail += "--- errors ---\n" + "\n".join(causes) + "\n"
|
|
296
|
+
if stderr_tail.strip():
|
|
297
|
+
error_detail += f"--- stderr ---\n{stderr_tail}\n"
|
|
298
|
+
if stdout_tail.strip():
|
|
299
|
+
error_detail += f"--- stdout ---\n{stdout_tail}"
|
|
300
|
+
|
|
301
|
+
if missing_deps:
|
|
302
|
+
error_detail += (
|
|
303
|
+
"\n--- missing dependencies ---\n"
|
|
304
|
+
+ "\n".join(f" {d}" for d in missing_deps)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
return False, None, error_detail, failure_type, missing_deps
|
|
308
|
+
|
|
309
|
+
jar_files = find_output_jars(repo_path)
|
|
310
|
+
|
|
311
|
+
if not jar_files:
|
|
312
|
+
return (False, None, "No mod JAR found in any build/libs",
|
|
313
|
+
FailureType.BUILD_ERROR, [])
|
|
314
|
+
|
|
315
|
+
main_jar = select_main_jar(jar_files, mc_version)
|
|
316
|
+
|
|
317
|
+
return True, main_jar, "Compilation successful", FailureType.NONE, []
|
|
318
|
+
|
|
319
|
+
except subprocess.TimeoutExpired:
|
|
320
|
+
return (False, None, "Compilation timeout (>10 minutes)",
|
|
321
|
+
FailureType.TIMEOUT, [])
|
|
322
|
+
except Exception as e:
|
|
323
|
+
return (False, None, f"Compilation error: {e}",
|
|
324
|
+
FailureType.UNKNOWN, [])
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _validate_toml_jar(
|
|
328
|
+
jar: zipfile.ZipFile, toml_path: str, mc_version: str
|
|
329
|
+
) -> Tuple[bool, Optional[str], Optional[str], str]:
|
|
330
|
+
"""Validate a TOML-based mod JAR (NeoForge/Forge mods.toml)."""
|
|
331
|
+
with jar.open(toml_path) as f:
|
|
332
|
+
toml_content = f.read().decode('utf-8')
|
|
333
|
+
mod_info = toml.loads(toml_content)
|
|
334
|
+
|
|
335
|
+
if 'mods' in mod_info and len(mod_info['mods']) > 0:
|
|
336
|
+
first_mod = mod_info['mods'][0]
|
|
337
|
+
mod_name = first_mod.get('modId', 'unknown')
|
|
338
|
+
mod_version = first_mod.get('version', 'unknown')
|
|
339
|
+
else:
|
|
340
|
+
mod_name = 'unknown'
|
|
341
|
+
mod_version = 'unknown'
|
|
342
|
+
|
|
343
|
+
if 'dependencies' in mod_info:
|
|
344
|
+
for mod_id, dep_info in mod_info['dependencies'].items():
|
|
345
|
+
if isinstance(dep_info, list):
|
|
346
|
+
for dep in dep_info:
|
|
347
|
+
if dep.get('modId') == 'minecraft':
|
|
348
|
+
version_range = dep.get('versionRange', '')
|
|
349
|
+
if version_range and not is_version_in_maven_range(mc_version, version_range):
|
|
350
|
+
return False, mod_name, mod_version, f"JAR declares incompatible MC version: {version_range}"
|
|
351
|
+
|
|
352
|
+
return True, mod_name, mod_version, "JAR validation passed"
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _validate_json_jar(
|
|
356
|
+
jar: zipfile.ZipFile, json_path: str, mc_version: str
|
|
357
|
+
) -> Tuple[bool, Optional[str], Optional[str], str]:
|
|
358
|
+
"""Validate a JSON-based mod JAR (Fabric/Quilt)."""
|
|
359
|
+
with jar.open(json_path) as f:
|
|
360
|
+
data = json.loads(f.read().decode('utf-8'))
|
|
361
|
+
|
|
362
|
+
# Quilt schema: quilt_loader.id, quilt_loader.version
|
|
363
|
+
quilt_loader = data.get('quilt_loader', {})
|
|
364
|
+
if quilt_loader:
|
|
365
|
+
mod_name = quilt_loader.get('id', 'unknown')
|
|
366
|
+
mod_version_val = quilt_loader.get('version', 'unknown')
|
|
367
|
+
for dep in quilt_loader.get('depends', []):
|
|
368
|
+
if isinstance(dep, dict) and dep.get('id') == 'minecraft':
|
|
369
|
+
versions = dep.get('versions', '')
|
|
370
|
+
# A list means "any of these" in quilt.mod.json too
|
|
371
|
+
options = [versions] if isinstance(versions, str) else (
|
|
372
|
+
[v for v in versions if isinstance(v, str)]
|
|
373
|
+
if isinstance(versions, list) else [])
|
|
374
|
+
options = [v for v in options if v]
|
|
375
|
+
if options and not any(is_version_in_fabric_range(mc_version, v)
|
|
376
|
+
for v in options):
|
|
377
|
+
return False, mod_name, mod_version_val, f"JAR declares incompatible MC version: {versions}"
|
|
378
|
+
return True, mod_name, mod_version_val, "JAR validation passed"
|
|
379
|
+
|
|
380
|
+
# Fabric schema: id, version, depends.minecraft
|
|
381
|
+
mod_name = data.get('id', 'unknown')
|
|
382
|
+
mod_version_val = data.get('version', 'unknown')
|
|
383
|
+
|
|
384
|
+
depends = data.get('depends', {})
|
|
385
|
+
mc_range = depends.get('minecraft', '')
|
|
386
|
+
# A list means "any of these" in fabric.mod.json
|
|
387
|
+
alternatives = [mc_range] if isinstance(mc_range, str) else (
|
|
388
|
+
[r for r in mc_range if isinstance(r, str)] if isinstance(mc_range, list) else [])
|
|
389
|
+
alternatives = [r for r in alternatives if r]
|
|
390
|
+
if alternatives and not any(is_version_in_fabric_range(mc_version, r) for r in alternatives):
|
|
391
|
+
return False, mod_name, mod_version_val, f"JAR declares incompatible MC version: {mc_range}"
|
|
392
|
+
|
|
393
|
+
return True, mod_name, mod_version_val, "JAR validation passed"
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def validate_jar(
|
|
397
|
+
jar_path: Path, mc_version: str, min_size: int = 10 * 1024
|
|
398
|
+
) -> Tuple[bool, Optional[str], Optional[str], str]:
|
|
399
|
+
"""
|
|
400
|
+
Validate the compiled JAR file.
|
|
401
|
+
Returns (is_valid, mod_name, mod_version, message)
|
|
402
|
+
|
|
403
|
+
min_size guards against picking a stub among a build's outputs (slim/API jars). A
|
|
404
|
+
published release has no such ambiguity, so its callers pass 0: real mods can be tiny
|
|
405
|
+
(Monsters in the Closet 1.0.3 is 10,238 bytes).
|
|
406
|
+
"""
|
|
407
|
+
try:
|
|
408
|
+
with zipfile.ZipFile(jar_path, 'r') as jar:
|
|
409
|
+
jar_names = set(jar.namelist())
|
|
410
|
+
|
|
411
|
+
# Try each loader profile's metadata files
|
|
412
|
+
for loader_name, profile in LOADER_PROFILES.items():
|
|
413
|
+
fmt = profile["jar_metadata_format"]
|
|
414
|
+
for metadata_file in profile["jar_metadata_files"]:
|
|
415
|
+
if metadata_file in jar_names:
|
|
416
|
+
if fmt == "toml":
|
|
417
|
+
valid, name, ver, msg = _validate_toml_jar(
|
|
418
|
+
jar, metadata_file, mc_version
|
|
419
|
+
)
|
|
420
|
+
else:
|
|
421
|
+
valid, name, ver, msg = _validate_json_jar(
|
|
422
|
+
jar, metadata_file, mc_version
|
|
423
|
+
)
|
|
424
|
+
if not valid:
|
|
425
|
+
return valid, name, ver, msg
|
|
426
|
+
if jar_path.stat().st_size < min_size:
|
|
427
|
+
return False, name, ver, "JAR file suspiciously small (<10KB)"
|
|
428
|
+
return True, name, ver, msg
|
|
429
|
+
|
|
430
|
+
return False, None, None, "No recognized mod metadata found in JAR"
|
|
431
|
+
|
|
432
|
+
except zipfile.BadZipFile:
|
|
433
|
+
return False, None, None, "Invalid JAR file (corrupted)"
|
|
434
|
+
except Exception as e:
|
|
435
|
+
return False, None, None, f"JAR validation error: {e}"
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def create_maven_local_init_script(temp_dir: str) -> Path:
|
|
439
|
+
"""Create a Gradle init script that injects mavenLocal()."""
|
|
440
|
+
init_script = Path(temp_dir) / "maven-local-init.gradle"
|
|
441
|
+
init_script.write_text(
|
|
442
|
+
"allprojects {\n"
|
|
443
|
+
" repositories {\n"
|
|
444
|
+
" mavenLocal()\n"
|
|
445
|
+
" }\n"
|
|
446
|
+
"}\n",
|
|
447
|
+
encoding="utf-8"
|
|
448
|
+
)
|
|
449
|
+
return init_script
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def publish_to_maven_local(
|
|
453
|
+
repo_path: Path,
|
|
454
|
+
extra_gradle_args: Optional[List[str]] = None
|
|
455
|
+
) -> bool:
|
|
456
|
+
"""Run publishToMavenLocal on a successfully compiled repo."""
|
|
457
|
+
root, gradlew = resolve_gradle(repo_path)
|
|
458
|
+
if not gradlew:
|
|
459
|
+
return False
|
|
460
|
+
repo_path = root
|
|
461
|
+
|
|
462
|
+
# Release builds often GPG-sign their publications; a local publish needs no signature
|
|
463
|
+
# and would otherwise fail with "no configured signatory".
|
|
464
|
+
no_signing = repo_path / ".modkeel-no-signing.gradle"
|
|
465
|
+
no_signing.write_text(
|
|
466
|
+
"allprojects {\n"
|
|
467
|
+
" tasks.withType(Sign).configureEach { enabled = false }\n"
|
|
468
|
+
"}\n",
|
|
469
|
+
encoding="utf-8"
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
cmd = [str(gradlew), "publishToMavenLocal", "--no-daemon", "-x", "test",
|
|
473
|
+
"--init-script", str(no_signing)]
|
|
474
|
+
if extra_gradle_args:
|
|
475
|
+
cmd.extend(extra_gradle_args)
|
|
476
|
+
|
|
477
|
+
try:
|
|
478
|
+
result = subprocess.run(
|
|
479
|
+
cmd,
|
|
480
|
+
cwd=repo_path,
|
|
481
|
+
capture_output=True,
|
|
482
|
+
text=True,
|
|
483
|
+
timeout=600
|
|
484
|
+
)
|
|
485
|
+
if result.returncode == 0:
|
|
486
|
+
print(" \U0001f4e4 Published to Maven Local (~/.m2/repository/)")
|
|
487
|
+
return True
|
|
488
|
+
else:
|
|
489
|
+
logger.debug(
|
|
490
|
+
"publishToMavenLocal failed (task may not exist): %s",
|
|
491
|
+
result.stderr[:200] if result.stderr else ""
|
|
492
|
+
)
|
|
493
|
+
return False
|
|
494
|
+
except (subprocess.TimeoutExpired, Exception) as e:
|
|
495
|
+
logger.debug("publishToMavenLocal error: %s", e)
|
|
496
|
+
return False
|