modforge-cli 0.1.5__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.
- modforge_cli/__init__.py +5 -0
- modforge_cli/__main__.py +8 -0
- modforge_cli/__version__.py +5 -0
- modforge_cli/api/__init__.py +7 -0
- modforge_cli/api/modrinth.py +218 -0
- modforge_cli/cli.py +559 -0
- modforge_cli/core/__init__.py +6 -0
- modforge_cli/core/downloader.py +89 -0
- modforge_cli/core/models.py +72 -0
- modforge_cli/core/policy.py +162 -0
- modforge_cli/core/resolver.py +134 -0
- modforge_cli-0.1.5.dist-info/METADATA +70 -0
- modforge_cli-0.1.5.dist-info/RECORD +16 -0
- modforge_cli-0.1.5.dist-info/WHEEL +4 -0
- modforge_cli-0.1.5.dist-info/entry_points.txt +3 -0
- modforge_cli-0.1.5.dist-info/licenses/LICENSE +21 -0
modforge_cli/cli.py
ADDED
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import aiohttp
|
|
10
|
+
import typer
|
|
11
|
+
from pyfiglet import figlet_format
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.prompt import Confirm
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
18
|
+
from modforge_cli.api import ModrinthAPIConfig
|
|
19
|
+
from modforge_cli.core import Manifest, ModDownloader, SearchResult
|
|
20
|
+
|
|
21
|
+
# Import version info
|
|
22
|
+
try:
|
|
23
|
+
from modforge_cli.__version__ import __author__, __version__
|
|
24
|
+
except ImportError:
|
|
25
|
+
__version__ = "unknown"
|
|
26
|
+
__author__ = "Frank1o3"
|
|
27
|
+
|
|
28
|
+
app = typer.Typer(
|
|
29
|
+
add_completion=False,
|
|
30
|
+
no_args_is_help=False, # We handle this manually in the callback for the banner
|
|
31
|
+
)
|
|
32
|
+
console = Console()
|
|
33
|
+
FABRIC_LOADER_VERSION = "0.18.3"
|
|
34
|
+
CONFIG_PATH = Path().home() / ".config" / "ModForge-CLI"
|
|
35
|
+
REGISTRY_PATH = CONFIG_PATH / "registry.json"
|
|
36
|
+
MODRINTH_API = CONFIG_PATH / "modrinth_api.json"
|
|
37
|
+
POLICY_PATH = CONFIG_PATH / "policy.json"
|
|
38
|
+
|
|
39
|
+
FABRIC_INSTALLER_URL = (
|
|
40
|
+
"https://maven.fabricmc.net/net/fabricmc/"
|
|
41
|
+
"fabric-installer/1.1.1/fabric-installer-1.1.1.jar"
|
|
42
|
+
)
|
|
43
|
+
DEFAULT_MODRINTH_API_URL = "https://raw.githubusercontent.com/Frank1o3/ModForge-CLI/refs/heads/main/configs/modrinth_api.json"
|
|
44
|
+
|
|
45
|
+
DEFAULT_POLICY_URL = "https://raw.githubusercontent.com/Frank1o3/ModForge-CLI/refs/heads/main/configs/policy.json"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def ensure_config_file(path: Path, url: str, label: str):
|
|
49
|
+
if path.exists():
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
|
|
54
|
+
console.print(f"[yellow]Missing {label} config.[/yellow] Downloading default…")
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
import urllib.request
|
|
58
|
+
|
|
59
|
+
urllib.request.urlretrieve(url, path)
|
|
60
|
+
console.print(f"[green]✓ {label} config installed at {path}[/green]")
|
|
61
|
+
except Exception as e:
|
|
62
|
+
console.print(f"[red]Failed to download {label} config:[/red] {e}")
|
|
63
|
+
raise typer.Exit(1)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
ensure_config_file(
|
|
67
|
+
MODRINTH_API,
|
|
68
|
+
DEFAULT_MODRINTH_API_URL,
|
|
69
|
+
"Modrinth API",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
ensure_config_file(
|
|
73
|
+
POLICY_PATH,
|
|
74
|
+
DEFAULT_POLICY_URL,
|
|
75
|
+
"Policy",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
api = ModrinthAPIConfig(MODRINTH_API)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# --- Async Helper ---
|
|
83
|
+
async def get_api_session():
|
|
84
|
+
"""Returns a session with the correct ModForge-CLI headers."""
|
|
85
|
+
return aiohttp.ClientSession(
|
|
86
|
+
headers={"User-Agent": f"{__author__}/ModForge-CLI/{__version__}"},
|
|
87
|
+
raise_for_status=True,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_manifest(path: Path = Path.cwd()) -> Optional[Manifest]:
|
|
92
|
+
p = path / "ModForge-CLI.json"
|
|
93
|
+
if not p.exists():
|
|
94
|
+
return None
|
|
95
|
+
try:
|
|
96
|
+
return Manifest.model_validate_json(p.read_text())
|
|
97
|
+
except Exception as e:
|
|
98
|
+
console.print(e)
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def install_fabric(
|
|
103
|
+
installer: Path,
|
|
104
|
+
mc_version: str,
|
|
105
|
+
loader_version: str,
|
|
106
|
+
game_dir: Path,
|
|
107
|
+
):
|
|
108
|
+
subprocess.run(
|
|
109
|
+
[
|
|
110
|
+
"java",
|
|
111
|
+
"-jar",
|
|
112
|
+
installer,
|
|
113
|
+
"client",
|
|
114
|
+
"-mcversion",
|
|
115
|
+
mc_version,
|
|
116
|
+
"-loader",
|
|
117
|
+
loader_version,
|
|
118
|
+
"-dir",
|
|
119
|
+
str(game_dir),
|
|
120
|
+
"-noprofile",
|
|
121
|
+
],
|
|
122
|
+
check=True,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def detect_install_method() -> str:
|
|
127
|
+
prefix = Path(sys.prefix)
|
|
128
|
+
|
|
129
|
+
if "pipx" in prefix.parts:
|
|
130
|
+
return "pipx"
|
|
131
|
+
return "pip"
|
|
132
|
+
|
|
133
|
+
def self_update():
|
|
134
|
+
method = detect_install_method()
|
|
135
|
+
|
|
136
|
+
if method == "pipx":
|
|
137
|
+
console.print("[cyan]Updating ModForge-CLI using pipx...[/cyan]")
|
|
138
|
+
subprocess.run(["pipx", "upgrade", "ModForge-CLI"], check=True)
|
|
139
|
+
|
|
140
|
+
else:
|
|
141
|
+
console.print("[cyan]Updating ModForge-CLI using pip...[/cyan]")
|
|
142
|
+
subprocess.run(
|
|
143
|
+
[sys.executable, "-m", "pip", "install", "--upgrade", "ModForge-CLI"],
|
|
144
|
+
check=True,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
console.print("[green]ModForge-CLI updated successfully.[/green]")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def render_banner():
|
|
151
|
+
"""Renders a high-quality stylized banner"""
|
|
152
|
+
ascii_art = figlet_format("ModForge-CLI", font="slant")
|
|
153
|
+
|
|
154
|
+
# Create a colorful gradient-like effect for the text
|
|
155
|
+
banner_text = Text(ascii_art, style="bold cyan")
|
|
156
|
+
|
|
157
|
+
# Add extra info line
|
|
158
|
+
info_line = Text.assemble(
|
|
159
|
+
(" ⛏ ", "yellow"),
|
|
160
|
+
(f"v{__version__}", "bold white"),
|
|
161
|
+
(" | ", "dim"),
|
|
162
|
+
("Created by ", "italic white"),
|
|
163
|
+
(f"{__author__}", "bold magenta"),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Wrap in a nice panel
|
|
167
|
+
console.print(
|
|
168
|
+
Panel(
|
|
169
|
+
Text.assemble(banner_text, "\n", info_line),
|
|
170
|
+
border_style="blue",
|
|
171
|
+
padding=(1, 2),
|
|
172
|
+
expand=False,
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@app.callback(invoke_without_command=True)
|
|
178
|
+
def main_callback(
|
|
179
|
+
ctx: typer.Context,
|
|
180
|
+
version: Optional[bool] = typer.Option(
|
|
181
|
+
None, "--version", "-v", help="Show version and exit"
|
|
182
|
+
),
|
|
183
|
+
):
|
|
184
|
+
"""
|
|
185
|
+
ModForge-CLI: A powerful Minecraft modpack manager for Modrinth.
|
|
186
|
+
"""
|
|
187
|
+
if version:
|
|
188
|
+
console.print(f"ModForge-CLI Version: [bold cyan]{__version__}[/bold cyan]")
|
|
189
|
+
raise typer.Exit()
|
|
190
|
+
|
|
191
|
+
# If no command is provided (e.g., just 'ModForge-CLI')
|
|
192
|
+
if ctx.invoked_subcommand is None:
|
|
193
|
+
render_banner()
|
|
194
|
+
console.print("\n[bold yellow]Usage:[/bold yellow] ModForge-CLI [COMMAND] [ARGS]...")
|
|
195
|
+
console.print("\n[bold cyan]Core Commands:[/bold cyan]")
|
|
196
|
+
console.print(" [green]setup[/green] Initialize a new modpack project")
|
|
197
|
+
console.print(" [green]ls[/green] List all registered projects")
|
|
198
|
+
console.print(" [green]add[/green] Add a mod/resource/shader to manifest")
|
|
199
|
+
console.print(
|
|
200
|
+
" [green]build[/green] Download files and setup loader version"
|
|
201
|
+
)
|
|
202
|
+
console.print(" [green]export[/green] Create the final .mrpack zip")
|
|
203
|
+
|
|
204
|
+
console.print("\nRun [white]ModForge-CLI --help[/white] for full command details.\n")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@app.command()
|
|
208
|
+
def setup(name: str, mc: str = "1.21.1", loader: str = "fabric", loader_version: str = FABRIC_LOADER_VERSION):
|
|
209
|
+
"""Initialize the working directory for a new pack"""
|
|
210
|
+
pack_dir = Path.cwd() / name
|
|
211
|
+
pack_dir.mkdir(parents=True, exist_ok=True)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# Standard ModForge-CLI structure (The Watermark)
|
|
215
|
+
for folder in [
|
|
216
|
+
"mods",
|
|
217
|
+
"overrides/resourcepacks",
|
|
218
|
+
"overrides/shaderpacks",
|
|
219
|
+
"overrides/config",
|
|
220
|
+
"versions",
|
|
221
|
+
]:
|
|
222
|
+
(pack_dir / folder).mkdir(parents=True, exist_ok=True)
|
|
223
|
+
|
|
224
|
+
manifest: Manifest = Manifest(name=name, minecraft=mc, loader=loader, loader_version=loader_version)
|
|
225
|
+
(pack_dir / "ModForge-CLI.json").write_text(manifest.model_dump_json(indent=4))
|
|
226
|
+
|
|
227
|
+
# Register globally
|
|
228
|
+
registry: dict[str, str] = (
|
|
229
|
+
json.loads(REGISTRY_PATH.read_text()) if REGISTRY_PATH.exists() else {}
|
|
230
|
+
)
|
|
231
|
+
registry[name] = str(pack_dir.absolute())
|
|
232
|
+
REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
REGISTRY_PATH.write_text(json.dumps(registry, indent=4))
|
|
234
|
+
|
|
235
|
+
index_data: dict[str, dict[str, str] | list[str] | str | int] = {
|
|
236
|
+
"formatVersion": 1,
|
|
237
|
+
"game": "minecraft",
|
|
238
|
+
"versionId": "1.0.0",
|
|
239
|
+
"name": name,
|
|
240
|
+
"dependencies": {"minecraft": mc, loader: "*"},
|
|
241
|
+
"files": [],
|
|
242
|
+
|
|
243
|
+
}
|
|
244
|
+
(pack_dir / "modrinth.index.json").write_text(json.dumps(index_data, indent=2))
|
|
245
|
+
|
|
246
|
+
console.print(
|
|
247
|
+
f"Project [bold cyan]{name}[/bold cyan] ready at {pack_dir}", style="green"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@app.command()
|
|
252
|
+
def add(name: str, project_type: str = "mod", pack_name: str = "testpack"):
|
|
253
|
+
"""Search and add a project to the manifest without overwriting existing data"""
|
|
254
|
+
|
|
255
|
+
# --- API LIMITATION CHECK ---
|
|
256
|
+
if project_type in ["resourcepack", "shaderpack"]:
|
|
257
|
+
console.print(
|
|
258
|
+
f"[bold yellow]Notice:[/bold yellow] Adding {project_type}s is currently [red]Not Implemented[/red]. "
|
|
259
|
+
"The search API is currently limited to mods."
|
|
260
|
+
)
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
registry = json.loads(REGISTRY_PATH.read_text())
|
|
264
|
+
if pack_name not in registry:
|
|
265
|
+
console.print(f"[red]Error:[/red] Pack '{pack_name}' not found in registry.")
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
pack_path = Path(registry[pack_name])
|
|
269
|
+
manifest_file = pack_path / "ModForge-CLI.json"
|
|
270
|
+
|
|
271
|
+
manifest = get_manifest(pack_path)
|
|
272
|
+
if not manifest:
|
|
273
|
+
console.print(f"[red]Error:[/red] Could not load manifest at {manifest_file}")
|
|
274
|
+
return
|
|
275
|
+
|
|
276
|
+
async def perform_add():
|
|
277
|
+
async with await get_api_session() as session:
|
|
278
|
+
url = api.search(
|
|
279
|
+
name,
|
|
280
|
+
game_versions=[manifest.minecraft],
|
|
281
|
+
loaders=[manifest.loader],
|
|
282
|
+
project_type=project_type,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
async with session.get(url) as response:
|
|
286
|
+
results = SearchResult.model_validate_json(await response.text())
|
|
287
|
+
|
|
288
|
+
if not results or not results.hits:
|
|
289
|
+
console.print(f"[red]No {project_type} found for '{name}'")
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
# Match slug
|
|
293
|
+
target_hit = next(
|
|
294
|
+
(h for h in results.hits if h.slug == name), results.hits[0]
|
|
295
|
+
)
|
|
296
|
+
slug = target_hit.slug
|
|
297
|
+
|
|
298
|
+
# 3. Modify the existing manifest object
|
|
299
|
+
# Only 'mod' will reach here currently due to the check above
|
|
300
|
+
target_list = {
|
|
301
|
+
"mod": manifest.mods,
|
|
302
|
+
"resourcepack": manifest.resourcepacks,
|
|
303
|
+
"shaderpack": manifest.shaderpacks,
|
|
304
|
+
}.get(project_type, manifest.mods)
|
|
305
|
+
|
|
306
|
+
if slug not in target_list:
|
|
307
|
+
target_list.append(slug)
|
|
308
|
+
manifest_file.write_text(manifest.model_dump_json(indent=4))
|
|
309
|
+
console.print(f"Added [green]{slug}[/green] to {project_type}s")
|
|
310
|
+
else:
|
|
311
|
+
console.print(f"{slug} is already in the manifest.")
|
|
312
|
+
|
|
313
|
+
asyncio.run(perform_add())
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@app.command()
|
|
317
|
+
def resolve(pack_name: str = "testpack"):
|
|
318
|
+
from ModForge-CLI.core import ModPolicy, ModResolver
|
|
319
|
+
|
|
320
|
+
# 1. Load Registry and Manifest
|
|
321
|
+
registry = json.loads(REGISTRY_PATH.read_text())
|
|
322
|
+
if pack_name not in registry:
|
|
323
|
+
console.print(f"[red]Error:[/red] Pack '{pack_name}' not found in registry.")
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
pack_path = Path(registry[pack_name])
|
|
327
|
+
manifest_file = pack_path / "ModForge-CLI.json"
|
|
328
|
+
|
|
329
|
+
manifest = get_manifest(pack_path)
|
|
330
|
+
if not manifest:
|
|
331
|
+
console.print(f"[red]Error:[/red] Could not load manifest at {manifest_file}")
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
# 2. Run Resolution Logic
|
|
335
|
+
console.print(
|
|
336
|
+
f"Resolving dependencies for [bold cyan]{pack_name}[/bold cyan]...",
|
|
337
|
+
style="yellow",
|
|
338
|
+
)
|
|
339
|
+
policy = ModPolicy(POLICY_PATH)
|
|
340
|
+
resolver = ModResolver(
|
|
341
|
+
policy=policy, api=api, mc_version=manifest.minecraft, loader=manifest.loader
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# This returns a Set[str] of unique Modrinth Project IDs
|
|
345
|
+
resolved_mods = resolver.resolve(manifest.mods)
|
|
346
|
+
|
|
347
|
+
# 3. Update Manifest with Resolved IDs
|
|
348
|
+
# We convert the set to a sorted list for a clean JSON file
|
|
349
|
+
manifest.mods = sorted(list(resolved_mods))
|
|
350
|
+
|
|
351
|
+
# 4. Save back to ModForge-CLI.json
|
|
352
|
+
try:
|
|
353
|
+
manifest_file.write_text(manifest.model_dump_json(indent=4))
|
|
354
|
+
console.print(f"Successfully updated [bold]{manifest_file.name}[/bold]")
|
|
355
|
+
console.print(
|
|
356
|
+
f"Total mods resolved: [bold green]{len(manifest.mods)}[/bold green]"
|
|
357
|
+
)
|
|
358
|
+
except Exception as e:
|
|
359
|
+
console.print(f"[red]Error saving manifest:[/red] {e}")
|
|
360
|
+
|
|
361
|
+
# Optional: Print a summary table of the IDs
|
|
362
|
+
if manifest.mods:
|
|
363
|
+
table = Table(title=f"Resolved IDs for {pack_name}")
|
|
364
|
+
table.add_column("Project ID", style="green")
|
|
365
|
+
for mod_id in manifest.mods:
|
|
366
|
+
table.add_row(mod_id)
|
|
367
|
+
console.print(table)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@app.command()
|
|
371
|
+
def build(pack_name: str = "testpack"):
|
|
372
|
+
"""Download dependencies and set up the loader version"""
|
|
373
|
+
|
|
374
|
+
# 1. Load Registry and Manifest
|
|
375
|
+
registry = json.loads(REGISTRY_PATH.read_text())
|
|
376
|
+
if pack_name not in registry:
|
|
377
|
+
console.print(f"[red]Error:[/red] Pack '{pack_name}' not found in registry.")
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
pack_path = Path(registry[pack_name])
|
|
381
|
+
manifest_file = pack_path / "ModForge-CLI.json"
|
|
382
|
+
|
|
383
|
+
manifest = get_manifest(pack_path)
|
|
384
|
+
if not manifest:
|
|
385
|
+
console.print(f"[red]Error:[/red] Could not load manifest at {manifest_file}")
|
|
386
|
+
return
|
|
387
|
+
|
|
388
|
+
pack_root = Path.cwd() / manifest.name
|
|
389
|
+
mods_dir = pack_root / "mods"
|
|
390
|
+
index_file = pack_root / "modrinth.index.json"
|
|
391
|
+
|
|
392
|
+
mods_dir.mkdir(exist_ok=True)
|
|
393
|
+
|
|
394
|
+
async def run():
|
|
395
|
+
async with aiohttp.ClientSession(
|
|
396
|
+
headers={"User-Agent": f"{__author__}/ModForge-CLI/{__version__}"},
|
|
397
|
+
raise_for_status=True,
|
|
398
|
+
) as session:
|
|
399
|
+
downloader = ModDownloader(
|
|
400
|
+
api=api,
|
|
401
|
+
mc_version=manifest.minecraft,
|
|
402
|
+
loader=manifest.loader,
|
|
403
|
+
output_dir=mods_dir,
|
|
404
|
+
index_file=index_file,
|
|
405
|
+
session=session,
|
|
406
|
+
)
|
|
407
|
+
await downloader.download_all(manifest.mods)
|
|
408
|
+
|
|
409
|
+
console.print(f"🛠 Building [bold cyan]{manifest.name}[/bold cyan]...")
|
|
410
|
+
asyncio.run(run())
|
|
411
|
+
console.print("✨ Build complete. Mods downloaded and indexed.", style="green")
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@app.command()
|
|
415
|
+
def export(pack_name: str = "testpack"):
|
|
416
|
+
"""Finalize and export the pack as a runnable .zip"""
|
|
417
|
+
|
|
418
|
+
# 1. Load Registry and Manifest
|
|
419
|
+
registry = json.loads(REGISTRY_PATH.read_text())
|
|
420
|
+
if pack_name not in registry:
|
|
421
|
+
console.print(f"[red]Error:[/red] Pack '{pack_name}' not found in registry.")
|
|
422
|
+
return
|
|
423
|
+
|
|
424
|
+
pack_path = Path(registry[pack_name])
|
|
425
|
+
manifest_file = pack_path / "ModForge-CLI.json"
|
|
426
|
+
|
|
427
|
+
manifest = get_manifest(pack_path)
|
|
428
|
+
if not manifest:
|
|
429
|
+
console.print(f"[red]Error:[/red] Could not load manifest at {manifest_file}")
|
|
430
|
+
return
|
|
431
|
+
loader_version = manifest.loader_version or FABRIC_LOADER_VERSION
|
|
432
|
+
|
|
433
|
+
console.print("📦 Finalizing pack...", style="cyan")
|
|
434
|
+
|
|
435
|
+
mods_dir = Path.cwd() / manifest.name / "mods"
|
|
436
|
+
if not mods_dir.exists() or not any(mods_dir.iterdir()):
|
|
437
|
+
console.print("[red]No mods found. Run `ModForge-CLI build` first.[/red]")
|
|
438
|
+
raise typer.Exit(1)
|
|
439
|
+
|
|
440
|
+
if manifest.loader == "fabric":
|
|
441
|
+
installer = Path.cwd() / manifest.name / ".fabric-installer.jar"
|
|
442
|
+
|
|
443
|
+
if not installer.exists():
|
|
444
|
+
console.print("Downloading Fabric installer...")
|
|
445
|
+
import urllib.request
|
|
446
|
+
|
|
447
|
+
urllib.request.urlretrieve(FABRIC_INSTALLER_URL, installer)
|
|
448
|
+
|
|
449
|
+
console.print("Installing Fabric...")
|
|
450
|
+
install_fabric(
|
|
451
|
+
installer=installer,
|
|
452
|
+
mc_version=manifest.minecraft,
|
|
453
|
+
loader_version=loader_version,
|
|
454
|
+
game_dir=Path.cwd() / manifest.name,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
index_file = Path.cwd() / manifest.name / "modrinth.index.json"
|
|
458
|
+
index = json.loads(index_file.read_text())
|
|
459
|
+
index["dependencies"]["fabric-loader"] = FABRIC_LOADER_VERSION
|
|
460
|
+
index_file.write_text(json.dumps(index, indent=2))
|
|
461
|
+
|
|
462
|
+
installer.unlink(missing_ok=True)
|
|
463
|
+
|
|
464
|
+
pack_name = manifest.name
|
|
465
|
+
zip_path = Path.cwd().parent / f"{pack_name}.zip"
|
|
466
|
+
|
|
467
|
+
shutil.make_archive(
|
|
468
|
+
base_name=str(zip_path.with_suffix("")),
|
|
469
|
+
format="zip",
|
|
470
|
+
root_dir=Path.cwd(),
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
console.print(f"✅ Exported {zip_path.name}", style="green bold")
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@app.command()
|
|
477
|
+
def remove(pack_name: str):
|
|
478
|
+
"""Completely remove a modpack and unregister it"""
|
|
479
|
+
if not REGISTRY_PATH.exists():
|
|
480
|
+
console.print("[red]No registry found.[/red]")
|
|
481
|
+
raise typer.Exit(1)
|
|
482
|
+
|
|
483
|
+
registry = json.loads(REGISTRY_PATH.read_text())
|
|
484
|
+
|
|
485
|
+
if pack_name not in registry:
|
|
486
|
+
console.print(f"[red]Pack '{pack_name}' not found in registry.[/red]")
|
|
487
|
+
raise typer.Exit(1)
|
|
488
|
+
|
|
489
|
+
pack_path = Path(registry[pack_name])
|
|
490
|
+
|
|
491
|
+
console.print(
|
|
492
|
+
Panel.fit(
|
|
493
|
+
f"[bold red]This will permanently delete:[/bold red]\n\n"
|
|
494
|
+
f"[white]{pack_name}[/white]\n"
|
|
495
|
+
f"[dim]{pack_path}[/dim]",
|
|
496
|
+
title="⚠️ Destructive Action",
|
|
497
|
+
border_style="red",
|
|
498
|
+
)
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
if not Confirm.ask("Are you sure you want to continue?", default=False):
|
|
502
|
+
console.print("Aborted.", style="dim")
|
|
503
|
+
raise typer.Exit()
|
|
504
|
+
|
|
505
|
+
# Remove directory
|
|
506
|
+
try:
|
|
507
|
+
if pack_path.exists():
|
|
508
|
+
shutil.rmtree(pack_path)
|
|
509
|
+
else:
|
|
510
|
+
console.print(
|
|
511
|
+
f"[yellow]Warning:[/yellow] Pack directory does not exist: {pack_path}"
|
|
512
|
+
)
|
|
513
|
+
except Exception as e:
|
|
514
|
+
console.print(f"[red]Failed to delete pack directory:[/red] {e}")
|
|
515
|
+
raise typer.Exit(1)
|
|
516
|
+
|
|
517
|
+
# Update registry
|
|
518
|
+
del registry[pack_name]
|
|
519
|
+
REGISTRY_PATH.write_text(json.dumps(registry, indent=4))
|
|
520
|
+
|
|
521
|
+
console.print(
|
|
522
|
+
f"🗑️ Removed pack [bold cyan]{pack_name}[/bold cyan] successfully.",
|
|
523
|
+
style="green",
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
@app.command(name="ls")
|
|
528
|
+
def list_projects():
|
|
529
|
+
"""Show all ModForge-CLI projects"""
|
|
530
|
+
if not REGISTRY_PATH.exists():
|
|
531
|
+
console.print("No projects registered.")
|
|
532
|
+
return
|
|
533
|
+
|
|
534
|
+
registry = json.loads(REGISTRY_PATH.read_text())
|
|
535
|
+
table = Table(title="ModForge-CLI Managed Packs", header_style="bold magenta")
|
|
536
|
+
table.add_column("Pack Name", style="cyan")
|
|
537
|
+
table.add_column("Location", style="dim")
|
|
538
|
+
|
|
539
|
+
for name, path in registry.items():
|
|
540
|
+
table.add_row(name, path)
|
|
541
|
+
console.print(table)
|
|
542
|
+
|
|
543
|
+
@app.command()
|
|
544
|
+
def self_update_cmd():
|
|
545
|
+
"""
|
|
546
|
+
Update ModForge-CLI to the latest version.
|
|
547
|
+
"""
|
|
548
|
+
try:
|
|
549
|
+
self_update()
|
|
550
|
+
except subprocess.CalledProcessError:
|
|
551
|
+
raise typer.Exit(code=1)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def main():
|
|
555
|
+
app()
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
if __name__ == "__main__":
|
|
559
|
+
main()
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
from .policy import ModPolicy
|
|
2
|
+
from .resolver import ModResolver
|
|
3
|
+
from .downloader import ModDownloader
|
|
4
|
+
from .models import Manifest, Hit, SearchResult, ProjectVersion, ProjectVersionList
|
|
5
|
+
|
|
6
|
+
__all__ = ["ModPolicy", "ModResolver", "Manifest", "Hit", "SearchResult", "ProjectVersion", "ProjectVersionList", "ModDownloader"]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Iterable
|
|
8
|
+
|
|
9
|
+
import aiohttp
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.progress import Progress, SpinnerColumn, BarColumn, 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]):
|
|
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):
|
|
55
|
+
# 1. Fetch compatible version
|
|
56
|
+
url = self.api.project_versions(
|
|
57
|
+
project_id
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
async with self.session.get(url) as r:
|
|
61
|
+
versions = await r.json()
|
|
62
|
+
|
|
63
|
+
if not versions:
|
|
64
|
+
console.print(f"[yellow]No compatible version for {project_id}[/yellow]")
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
version = versions[0]
|
|
68
|
+
file = next(f for f in version["files"] if f["primary"])
|
|
69
|
+
|
|
70
|
+
# 2. Download file
|
|
71
|
+
dest = self.output_dir / file["filename"]
|
|
72
|
+
async with self.session.get(file["url"]) as r:
|
|
73
|
+
data = await r.read()
|
|
74
|
+
dest.write_bytes(data)
|
|
75
|
+
|
|
76
|
+
# 3. Verify hash
|
|
77
|
+
sha1 = hashlib.sha1(data).hexdigest()
|
|
78
|
+
if sha1 != file["hashes"]["sha1"]:
|
|
79
|
+
raise RuntimeError(f"Hash mismatch for {file['filename']}")
|
|
80
|
+
|
|
81
|
+
# 4. Register in index
|
|
82
|
+
self.index["files"].append(
|
|
83
|
+
{
|
|
84
|
+
"path": f"mods/{file['filename']}",
|
|
85
|
+
"hashes": {"sha1": sha1},
|
|
86
|
+
"downloads": [file["url"]],
|
|
87
|
+
"fileSize": file["size"],
|
|
88
|
+
}
|
|
89
|
+
)
|