modforge-cli 0.1.9__tar.gz → 0.2.0__tar.gz
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.
- {modforge_cli-0.1.9 → modforge_cli-0.2.0}/PKG-INFO +1 -1
- {modforge_cli-0.1.9 → modforge_cli-0.2.0}/pyproject.toml +1 -1
- {modforge_cli-0.1.9 → modforge_cli-0.2.0}/src/modforge_cli/__version__.py +1 -1
- {modforge_cli-0.1.9 → modforge_cli-0.2.0}/src/modforge_cli/cli.py +37 -35
- modforge_cli-0.2.0/src/modforge_cli/core/downloader.py +211 -0
- modforge_cli-0.1.9/src/modforge_cli/core/downloader.py +0 -87
- {modforge_cli-0.1.9 → modforge_cli-0.2.0}/LICENSE +0 -0
- {modforge_cli-0.1.9 → modforge_cli-0.2.0}/README.md +0 -0
- {modforge_cli-0.1.9 → modforge_cli-0.2.0}/src/modforge_cli/__init__.py +0 -0
- {modforge_cli-0.1.9 → modforge_cli-0.2.0}/src/modforge_cli/__main__.py +0 -0
- {modforge_cli-0.1.9 → modforge_cli-0.2.0}/src/modforge_cli/api/__init__.py +0 -0
- {modforge_cli-0.1.9 → modforge_cli-0.2.0}/src/modforge_cli/api/modrinth.py +0 -0
- {modforge_cli-0.1.9 → modforge_cli-0.2.0}/src/modforge_cli/core/__init__.py +0 -0
- {modforge_cli-0.1.9 → modforge_cli-0.2.0}/src/modforge_cli/core/models.py +0 -0
- {modforge_cli-0.1.9 → modforge_cli-0.2.0}/src/modforge_cli/core/policy.py +0 -0
- {modforge_cli-0.1.9 → modforge_cli-0.2.0}/src/modforge_cli/core/resolver.py +0 -0
- {modforge_cli-0.1.9 → modforge_cli-0.2.0}/src/modforge_cli/core/utils.py +0 -0
|
@@ -5,7 +5,6 @@ from pathlib import Path
|
|
|
5
5
|
import shutil
|
|
6
6
|
import subprocess
|
|
7
7
|
import sys
|
|
8
|
-
from typing import Optional
|
|
9
8
|
import urllib.request
|
|
10
9
|
|
|
11
10
|
from pyfiglet import figlet_format
|
|
@@ -47,7 +46,7 @@ app = typer.Typer(
|
|
|
47
46
|
console = Console()
|
|
48
47
|
|
|
49
48
|
# Configuration
|
|
50
|
-
FABRIC_LOADER_VERSION = "0.
|
|
49
|
+
FABRIC_LOADER_VERSION = "0.18.4"
|
|
51
50
|
CONFIG_PATH = Path.home() / ".config" / "ModForge-CLI"
|
|
52
51
|
REGISTRY_PATH = CONFIG_PATH / "registry.json"
|
|
53
52
|
MODRINTH_API = CONFIG_PATH / "modrinth_api.json"
|
|
@@ -78,7 +77,7 @@ ensure_config_file(POLICY_PATH, DEFAULT_POLICY_URL, "Policy", console)
|
|
|
78
77
|
api = ModrinthAPIConfig(MODRINTH_API)
|
|
79
78
|
|
|
80
79
|
|
|
81
|
-
def render_banner():
|
|
80
|
+
def render_banner() -> None:
|
|
82
81
|
"""Renders a stylized banner"""
|
|
83
82
|
width = console.width
|
|
84
83
|
font = "slant" if width > 60 else "small"
|
|
@@ -108,13 +107,14 @@ def render_banner():
|
|
|
108
107
|
@app.callback(invoke_without_command=True)
|
|
109
108
|
def main_callback(
|
|
110
109
|
ctx: typer.Context,
|
|
111
|
-
version:
|
|
112
|
-
verbose:
|
|
113
|
-
):
|
|
110
|
+
version: bool | None = typer.Option(None, "--version", "-v", help="Show version and exit"),
|
|
111
|
+
verbose: bool | None = typer.Option(None, "--verbose", help="Enable verbose logging"),
|
|
112
|
+
) -> None:
|
|
114
113
|
"""ModForge-CLI: A powerful Minecraft modpack manager for Modrinth."""
|
|
115
114
|
|
|
116
115
|
if verbose:
|
|
117
116
|
# Enable verbose logging
|
|
117
|
+
|
|
118
118
|
logging.basicConfig(
|
|
119
119
|
level=logging.DEBUG,
|
|
120
120
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
@@ -151,7 +151,7 @@ def setup(
|
|
|
151
151
|
mc: str = "1.21.1",
|
|
152
152
|
loader: str = "fabric",
|
|
153
153
|
loader_version: str = FABRIC_LOADER_VERSION,
|
|
154
|
-
):
|
|
154
|
+
) -> None:
|
|
155
155
|
"""Initialize a new modpack project"""
|
|
156
156
|
pack_dir = Path.cwd() / name
|
|
157
157
|
|
|
@@ -177,13 +177,22 @@ def setup(
|
|
|
177
177
|
(pack_dir / "ModForge-CLI.json").write_text(manifest.model_dump_json(indent=4))
|
|
178
178
|
|
|
179
179
|
# Create Modrinth index
|
|
180
|
+
# Map loader names to their dependency keys
|
|
181
|
+
loader_key_map = {
|
|
182
|
+
"fabric": "fabric-loader",
|
|
183
|
+
"quilt": "quilt-loader",
|
|
184
|
+
"forge": "forge",
|
|
185
|
+
"neoforge": "neoforge",
|
|
186
|
+
}
|
|
187
|
+
loader_key = loader_key_map.get(loader.lower(), loader.lower())
|
|
188
|
+
|
|
180
189
|
index_data = {
|
|
181
190
|
"formatVersion": 1,
|
|
182
191
|
"game": "minecraft",
|
|
183
192
|
"versionId": "1.0.0",
|
|
184
193
|
"name": name,
|
|
185
|
-
"dependencies": {"minecraft": mc,
|
|
186
|
-
"files": [],
|
|
194
|
+
"dependencies": {"minecraft": mc, loader_key: loader_version},
|
|
195
|
+
"files": [], # Only for overrides, not mods
|
|
187
196
|
}
|
|
188
197
|
(pack_dir / "modrinth.index.json").write_text(json.dumps(index_data, indent=2))
|
|
189
198
|
|
|
@@ -197,7 +206,7 @@ def setup(
|
|
|
197
206
|
|
|
198
207
|
|
|
199
208
|
@app.command()
|
|
200
|
-
def add(name: str, project_type: str = "mod", pack_name:
|
|
209
|
+
def add(name: str, project_type: str = "mod", pack_name: str | None = None) -> None:
|
|
201
210
|
"""Add a project to the manifest"""
|
|
202
211
|
|
|
203
212
|
if project_type not in ["mod", "resourcepack", "shaderpack"]:
|
|
@@ -219,7 +228,7 @@ def add(name: str, project_type: str = "mod", pack_name: Optional[str] = None):
|
|
|
219
228
|
if pack_name not in registry:
|
|
220
229
|
console.print(f"[red]Pack '{pack_name}' not found in registry[/red]")
|
|
221
230
|
console.print("[yellow]Available packs:[/yellow]")
|
|
222
|
-
for p in registry
|
|
231
|
+
for p in registry:
|
|
223
232
|
console.print(f" - {p}")
|
|
224
233
|
raise typer.Exit(1)
|
|
225
234
|
|
|
@@ -235,7 +244,7 @@ def add(name: str, project_type: str = "mod", pack_name: Optional[str] = None):
|
|
|
235
244
|
|
|
236
245
|
|
|
237
246
|
@app.command()
|
|
238
|
-
def resolve(pack_name:
|
|
247
|
+
def resolve(pack_name: str | None = None) -> None:
|
|
239
248
|
"""Resolve all mod dependencies"""
|
|
240
249
|
|
|
241
250
|
# Auto-detect pack
|
|
@@ -257,7 +266,7 @@ def resolve(pack_name: Optional[str] = None):
|
|
|
257
266
|
|
|
258
267
|
manifest = get_manifest(console, pack_path)
|
|
259
268
|
if not manifest:
|
|
260
|
-
console.print(
|
|
269
|
+
console.print("[red]Could not load manifest[/red]")
|
|
261
270
|
raise typer.Exit(1)
|
|
262
271
|
|
|
263
272
|
console.print(f"[cyan]Resolving dependencies for {pack_name}...[/cyan]")
|
|
@@ -275,7 +284,7 @@ def resolve(pack_name: Optional[str] = None):
|
|
|
275
284
|
resolved_mods = asyncio.run(do_resolve())
|
|
276
285
|
except Exception as e:
|
|
277
286
|
console.print(f"[red]Resolution failed:[/red] {e}")
|
|
278
|
-
raise typer.Exit(1)
|
|
287
|
+
raise typer.Exit(1) from e
|
|
279
288
|
|
|
280
289
|
manifest.mods = sorted(list(resolved_mods))
|
|
281
290
|
manifest_file.write_text(manifest.model_dump_json(indent=4))
|
|
@@ -284,7 +293,7 @@ def resolve(pack_name: Optional[str] = None):
|
|
|
284
293
|
|
|
285
294
|
|
|
286
295
|
@app.command()
|
|
287
|
-
def build(pack_name:
|
|
296
|
+
def build(pack_name: str | None = None) -> None:
|
|
288
297
|
"""Download all mods and dependencies"""
|
|
289
298
|
|
|
290
299
|
if not pack_name:
|
|
@@ -318,11 +327,11 @@ def build(pack_name: Optional[str] = None):
|
|
|
318
327
|
console.print("[green]✓ Build complete[/green]")
|
|
319
328
|
except Exception as e:
|
|
320
329
|
console.print(f"[red]Build failed:[/red] {e}")
|
|
321
|
-
raise typer.Exit(1)
|
|
330
|
+
raise typer.Exit(1) from e
|
|
322
331
|
|
|
323
332
|
|
|
324
333
|
@app.command()
|
|
325
|
-
def export(pack_name:
|
|
334
|
+
def export(pack_name: str | None = None) -> None:
|
|
326
335
|
"""Create final .mrpack file"""
|
|
327
336
|
|
|
328
337
|
if not pack_name:
|
|
@@ -358,6 +367,7 @@ def export(pack_name: Optional[str] = None):
|
|
|
358
367
|
|
|
359
368
|
if not installer.exists():
|
|
360
369
|
console.print("[yellow]Downloading Fabric installer...[/yellow]")
|
|
370
|
+
|
|
361
371
|
|
|
362
372
|
urllib.request.urlretrieve(FABRIC_INSTALLER_URL, installer)
|
|
363
373
|
|
|
@@ -377,15 +387,10 @@ def export(pack_name: Optional[str] = None):
|
|
|
377
387
|
loader_version=loader_version,
|
|
378
388
|
game_dir=pack_path,
|
|
379
389
|
)
|
|
390
|
+
console.print(f"[green]✓ Fabric {loader_version} installed[/green]")
|
|
380
391
|
except RuntimeError as e:
|
|
381
392
|
console.print(f"[red]{e}[/red]")
|
|
382
|
-
raise typer.Exit(1)
|
|
383
|
-
|
|
384
|
-
# Update index
|
|
385
|
-
index_file = pack_path / "modrinth.index.json"
|
|
386
|
-
index = json.loads(index_file.read_text())
|
|
387
|
-
index["dependencies"]["fabric-loader"] = loader_version
|
|
388
|
-
index_file.write_text(json.dumps(index, indent=2))
|
|
393
|
+
raise typer.Exit(1) from e
|
|
389
394
|
|
|
390
395
|
installer.unlink(missing_ok=True)
|
|
391
396
|
|
|
@@ -406,7 +411,7 @@ def export(pack_name: Optional[str] = None):
|
|
|
406
411
|
|
|
407
412
|
|
|
408
413
|
@app.command()
|
|
409
|
-
def remove(pack_name: str):
|
|
414
|
+
def remove(pack_name: str) -> None:
|
|
410
415
|
"""Remove a modpack and unregister it"""
|
|
411
416
|
registry = load_registry(REGISTRY_PATH)
|
|
412
417
|
|
|
@@ -442,7 +447,7 @@ def remove(pack_name: str):
|
|
|
442
447
|
|
|
443
448
|
|
|
444
449
|
@app.command(name="ls")
|
|
445
|
-
def list_projects():
|
|
450
|
+
def list_projects() -> None:
|
|
446
451
|
"""List all registered modpacks"""
|
|
447
452
|
registry = load_registry(REGISTRY_PATH)
|
|
448
453
|
|
|
@@ -462,7 +467,7 @@ def list_projects():
|
|
|
462
467
|
|
|
463
468
|
|
|
464
469
|
@app.command()
|
|
465
|
-
def doctor():
|
|
470
|
+
def doctor() -> None:
|
|
466
471
|
"""Validate ModForge-CLI installation"""
|
|
467
472
|
console.print("[bold cyan]Running diagnostics...[/bold cyan]\n")
|
|
468
473
|
|
|
@@ -471,11 +476,7 @@ def doctor():
|
|
|
471
476
|
# Check Python version
|
|
472
477
|
|
|
473
478
|
py_version = f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
474
|
-
|
|
475
|
-
console.print(f"[green]✓[/green] Python {py_version}")
|
|
476
|
-
else:
|
|
477
|
-
console.print(f"[red]✗[/red] Python {py_version} (requires 3.10+)")
|
|
478
|
-
issues.append("Upgrade Python")
|
|
479
|
+
console.print(f"[green]✓[/green] Python {py_version}")
|
|
479
480
|
|
|
480
481
|
# Check config files
|
|
481
482
|
for name, path in [("API Config", MODRINTH_API), ("Policy", POLICY_PATH)]:
|
|
@@ -491,6 +492,7 @@ def doctor():
|
|
|
491
492
|
|
|
492
493
|
# Check Java
|
|
493
494
|
try:
|
|
495
|
+
|
|
494
496
|
result = subprocess.run(["java", "-version"], capture_output=True, text=True, check=True)
|
|
495
497
|
console.print("[green]✓[/green] Java installed")
|
|
496
498
|
except (FileNotFoundError, subprocess.CalledProcessError):
|
|
@@ -508,16 +510,16 @@ def doctor():
|
|
|
508
510
|
|
|
509
511
|
|
|
510
512
|
@app.command(name="self-update")
|
|
511
|
-
def self_update_cmd():
|
|
513
|
+
def self_update_cmd() -> None:
|
|
512
514
|
"""Update ModForge-CLI to latest version"""
|
|
513
515
|
try:
|
|
514
516
|
self_update(console)
|
|
515
517
|
except Exception as e:
|
|
516
518
|
console.print(f"[red]Update failed:[/red] {e}")
|
|
517
|
-
raise typer.Exit(1)
|
|
519
|
+
raise typer.Exit(1) from e
|
|
518
520
|
|
|
519
521
|
|
|
520
|
-
def main():
|
|
522
|
+
def main() -> None:
|
|
521
523
|
app()
|
|
522
524
|
|
|
523
525
|
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import aiohttp
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
|
|
12
|
+
|
|
13
|
+
from modforge_cli.api import ModrinthAPIConfig
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ModDownloader:
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
api: ModrinthAPIConfig,
|
|
22
|
+
mc_version: str,
|
|
23
|
+
loader: str,
|
|
24
|
+
output_dir: Path,
|
|
25
|
+
index_file: Path,
|
|
26
|
+
session: aiohttp.ClientSession,
|
|
27
|
+
):
|
|
28
|
+
self.api = api
|
|
29
|
+
self.mc_version = mc_version
|
|
30
|
+
self.loader = loader
|
|
31
|
+
self.output_dir = output_dir
|
|
32
|
+
self.index_file = index_file
|
|
33
|
+
self.session = session
|
|
34
|
+
|
|
35
|
+
self.index = json.loads(index_file.read_text())
|
|
36
|
+
|
|
37
|
+
def _select_compatible_version(self, versions: list[dict]) -> dict | None:
|
|
38
|
+
"""
|
|
39
|
+
Select the most appropriate version based on:
|
|
40
|
+
1. Loader compatibility (fabric/forge/quilt/neoforge)
|
|
41
|
+
2. Minecraft version
|
|
42
|
+
3. Version type (prefer release > beta > alpha)
|
|
43
|
+
"""
|
|
44
|
+
# Normalize loader name for comparison
|
|
45
|
+
loader_lower = self.loader.lower()
|
|
46
|
+
|
|
47
|
+
# Filter versions that match both MC version and loader
|
|
48
|
+
compatible = []
|
|
49
|
+
for v in versions:
|
|
50
|
+
# Check if MC version matches
|
|
51
|
+
if self.mc_version not in v.get("game_versions", []):
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
# Check if loader matches (case-insensitive)
|
|
55
|
+
loaders = [l.lower() for l in v.get("loaders", [])]
|
|
56
|
+
if loader_lower not in loaders:
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
compatible.append(v)
|
|
60
|
+
|
|
61
|
+
if not compatible:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
# Prioritize by version type: release > beta > alpha
|
|
65
|
+
version_priority = {"release": 3, "beta": 2, "alpha": 1}
|
|
66
|
+
|
|
67
|
+
def version_score(v):
|
|
68
|
+
vtype = v.get("version_type", "alpha")
|
|
69
|
+
return version_priority.get(vtype, 0)
|
|
70
|
+
|
|
71
|
+
# Sort by version type, then by date (newest first)
|
|
72
|
+
compatible.sort(key=lambda v: (version_score(v), v.get("date_published", "")), reverse=True)
|
|
73
|
+
|
|
74
|
+
return compatible[0]
|
|
75
|
+
|
|
76
|
+
async def download_all(self, project_ids: Iterable[str]) -> None:
|
|
77
|
+
"""
|
|
78
|
+
Download all mods and update modrinth.index.json dependencies.
|
|
79
|
+
|
|
80
|
+
Note: Modrinth launchers auto-download mods based on dependencies.
|
|
81
|
+
We download to mods/ folder for local use, but the index only needs
|
|
82
|
+
the version IDs in dependencies, not file paths.
|
|
83
|
+
"""
|
|
84
|
+
tasks = [self._download_project(pid) for pid in project_ids]
|
|
85
|
+
|
|
86
|
+
with Progress(
|
|
87
|
+
SpinnerColumn(),
|
|
88
|
+
TextColumn("[bold cyan]{task.description}"),
|
|
89
|
+
BarColumn(),
|
|
90
|
+
TextColumn("{task.completed}/{task.total}"),
|
|
91
|
+
console=console,
|
|
92
|
+
) as progress:
|
|
93
|
+
task_id = progress.add_task("Downloading mods", total=len(tasks))
|
|
94
|
+
for coro in asyncio.as_completed(tasks):
|
|
95
|
+
await coro
|
|
96
|
+
progress.advance(task_id)
|
|
97
|
+
|
|
98
|
+
# Update dependencies section with correct loader version
|
|
99
|
+
self._update_dependencies()
|
|
100
|
+
self.index_file.write_text(json.dumps(self.index, indent=2))
|
|
101
|
+
|
|
102
|
+
def _update_dependencies(self) -> None:
|
|
103
|
+
"""
|
|
104
|
+
Ensure dependencies section has correct MC version and loader.
|
|
105
|
+
This is what launchers use to setup the game.
|
|
106
|
+
"""
|
|
107
|
+
if "dependencies" not in self.index:
|
|
108
|
+
self.index["dependencies"] = {}
|
|
109
|
+
|
|
110
|
+
# Set Minecraft version
|
|
111
|
+
self.index["dependencies"]["minecraft"] = self.mc_version
|
|
112
|
+
|
|
113
|
+
# Set loader (fabric-loader, forge, quilt-loader, neoforge)
|
|
114
|
+
loader_key_map = {
|
|
115
|
+
"fabric": "fabric-loader",
|
|
116
|
+
"quilt": "quilt-loader",
|
|
117
|
+
"forge": "forge",
|
|
118
|
+
"neoforge": "neoforge",
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
loader_key = loader_key_map.get(self.loader.lower(), self.loader.lower())
|
|
122
|
+
|
|
123
|
+
# Use "*" to let launcher pick latest compatible version
|
|
124
|
+
# Or you can specify exact version if available
|
|
125
|
+
self.index["dependencies"][loader_key] = "*"
|
|
126
|
+
|
|
127
|
+
async def _download_project(self, project_id: str) -> None:
|
|
128
|
+
# 1. Fetch all versions for this project
|
|
129
|
+
url = self.api.project_versions(project_id)
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
async with self.session.get(url) as r:
|
|
133
|
+
if r.status != 200:
|
|
134
|
+
console.print(
|
|
135
|
+
f"[red]Failed to fetch versions for {project_id}: HTTP {r.status}[/red]"
|
|
136
|
+
)
|
|
137
|
+
return
|
|
138
|
+
versions = await r.json()
|
|
139
|
+
except Exception as e:
|
|
140
|
+
console.print(f"[red]Error fetching {project_id}: {e}[/red]")
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
if not versions:
|
|
144
|
+
console.print(f"[yellow]No versions found for {project_id}[/yellow]")
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
# 2. Select compatible version
|
|
148
|
+
version = self._select_compatible_version(versions)
|
|
149
|
+
|
|
150
|
+
if not version:
|
|
151
|
+
console.print(
|
|
152
|
+
f"[yellow]No compatible version for {project_id}[/yellow]\n"
|
|
153
|
+
f"[dim] Required: MC {self.mc_version}, Loader: {self.loader}[/dim]"
|
|
154
|
+
)
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
# 3. Find primary file
|
|
158
|
+
files = version.get("files", [])
|
|
159
|
+
primary_file = next((f for f in files if f.get("primary")), None)
|
|
160
|
+
|
|
161
|
+
if not primary_file and files:
|
|
162
|
+
# Fallback to first file if no primary is marked
|
|
163
|
+
primary_file = files[0]
|
|
164
|
+
|
|
165
|
+
if not primary_file:
|
|
166
|
+
console.print(
|
|
167
|
+
f"[yellow]No files found for {project_id} version {version.get('version_number')}[/yellow]"
|
|
168
|
+
)
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
# 4. Download file to mods/ directory
|
|
172
|
+
dest = self.output_dir / primary_file["filename"]
|
|
173
|
+
|
|
174
|
+
# Skip if already downloaded and hash matches
|
|
175
|
+
if dest.exists():
|
|
176
|
+
existing_hash = hashlib.sha1(dest.read_bytes()).hexdigest()
|
|
177
|
+
if existing_hash == primary_file["hashes"]["sha1"]:
|
|
178
|
+
console.print(f"[dim]✓ {primary_file['filename']} (cached)[/dim]")
|
|
179
|
+
return
|
|
180
|
+
else:
|
|
181
|
+
console.print(
|
|
182
|
+
f"[yellow]Re-downloading {primary_file['filename']} (hash mismatch)[/yellow]"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
async with self.session.get(primary_file["url"]) as r:
|
|
187
|
+
if r.status != 200:
|
|
188
|
+
console.print(
|
|
189
|
+
f"[red]Failed to download {primary_file['filename']}: HTTP {r.status}[/red]"
|
|
190
|
+
)
|
|
191
|
+
return
|
|
192
|
+
data = await r.read()
|
|
193
|
+
dest.write_bytes(data)
|
|
194
|
+
except Exception as e:
|
|
195
|
+
console.print(f"[red]Download error for {primary_file['filename']}: {e}[/red]")
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
# 5. Verify hash
|
|
199
|
+
sha1 = hashlib.sha1(data).hexdigest()
|
|
200
|
+
if sha1 != primary_file["hashes"]["sha1"]:
|
|
201
|
+
dest.unlink(missing_ok=True) # Delete corrupted file
|
|
202
|
+
raise RuntimeError(
|
|
203
|
+
f"Hash mismatch for {primary_file['filename']}\n"
|
|
204
|
+
f" Expected: {primary_file['hashes']['sha1']}\n"
|
|
205
|
+
f" Got: {sha1}"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
console.print(
|
|
209
|
+
f"[green]✓[/green] {primary_file['filename']} "
|
|
210
|
+
f"[dim](v{version.get('version_number')}, {self.loader})[/dim]"
|
|
211
|
+
)
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import asyncio
|
|
4
|
-
from collections.abc import Iterable
|
|
5
|
-
import hashlib
|
|
6
|
-
import json
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
|
|
9
|
-
import aiohttp
|
|
10
|
-
from rich.console import Console
|
|
11
|
-
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
|
|
12
|
-
|
|
13
|
-
from modforge_cli.api import ModrinthAPIConfig
|
|
14
|
-
|
|
15
|
-
console = Console()
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class ModDownloader:
|
|
19
|
-
def __init__(
|
|
20
|
-
self,
|
|
21
|
-
api: ModrinthAPIConfig,
|
|
22
|
-
mc_version: str,
|
|
23
|
-
loader: str,
|
|
24
|
-
output_dir: Path,
|
|
25
|
-
index_file: Path,
|
|
26
|
-
session: aiohttp.ClientSession,
|
|
27
|
-
):
|
|
28
|
-
self.api = api
|
|
29
|
-
self.mc_version = mc_version
|
|
30
|
-
self.loader = loader
|
|
31
|
-
self.output_dir = output_dir
|
|
32
|
-
self.index_file = index_file
|
|
33
|
-
self.session = session
|
|
34
|
-
|
|
35
|
-
self.index = json.loads(index_file.read_text())
|
|
36
|
-
|
|
37
|
-
async def download_all(self, project_ids: Iterable[str]) -> None:
|
|
38
|
-
tasks = [self._download_project(pid) for pid in project_ids]
|
|
39
|
-
|
|
40
|
-
with Progress(
|
|
41
|
-
SpinnerColumn(),
|
|
42
|
-
TextColumn("[bold cyan]{task.description}"),
|
|
43
|
-
BarColumn(),
|
|
44
|
-
TextColumn("{task.completed}/{task.total}"),
|
|
45
|
-
console=console,
|
|
46
|
-
) as progress:
|
|
47
|
-
task_id = progress.add_task("Downloading mods", total=len(tasks))
|
|
48
|
-
for coro in asyncio.as_completed(tasks):
|
|
49
|
-
await coro
|
|
50
|
-
progress.advance(task_id)
|
|
51
|
-
|
|
52
|
-
self.index_file.write_text(json.dumps(self.index, indent=2))
|
|
53
|
-
|
|
54
|
-
async def _download_project(self, project_id: str) -> None:
|
|
55
|
-
# 1. Fetch compatible version
|
|
56
|
-
url = self.api.project_versions(project_id)
|
|
57
|
-
|
|
58
|
-
async with self.session.get(url) as r:
|
|
59
|
-
versions = await r.json()
|
|
60
|
-
|
|
61
|
-
if not versions:
|
|
62
|
-
console.print(f"[yellow]No compatible version for {project_id}[/yellow]")
|
|
63
|
-
return
|
|
64
|
-
|
|
65
|
-
version = versions[0]
|
|
66
|
-
file = next(f for f in version["files"] if f["primary"])
|
|
67
|
-
|
|
68
|
-
# 2. Download file
|
|
69
|
-
dest = self.output_dir / file["filename"]
|
|
70
|
-
async with self.session.get(file["url"]) as r:
|
|
71
|
-
data = await r.read()
|
|
72
|
-
dest.write_bytes(data)
|
|
73
|
-
|
|
74
|
-
# 3. Verify hash
|
|
75
|
-
sha1 = hashlib.sha1(data).hexdigest()
|
|
76
|
-
if sha1 != file["hashes"]["sha1"]:
|
|
77
|
-
raise RuntimeError(f"Hash mismatch for {file['filename']}")
|
|
78
|
-
|
|
79
|
-
# 4. Register in index
|
|
80
|
-
self.index["files"].append(
|
|
81
|
-
{
|
|
82
|
-
"path": f"mods/{file['filename']}",
|
|
83
|
-
"hashes": {"sha1": sha1},
|
|
84
|
-
"downloads": [file["url"]],
|
|
85
|
-
"fileSize": file["size"],
|
|
86
|
-
}
|
|
87
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|