modforge-cli 0.2.3__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 +7 -0
- modforge_cli/__main__.py +132 -0
- modforge_cli/__version__.py +5 -0
- modforge_cli/api/__init__.py +7 -0
- modforge_cli/api/modrinth.py +210 -0
- modforge_cli/cli/__init__.py +7 -0
- modforge_cli/cli/export.py +246 -0
- modforge_cli/cli/modpack.py +150 -0
- modforge_cli/cli/project.py +72 -0
- modforge_cli/cli/setup.py +72 -0
- modforge_cli/cli/shared.py +41 -0
- modforge_cli/cli/sklauncher.py +125 -0
- modforge_cli/cli/utils.py +64 -0
- modforge_cli/core/__init__.py +39 -0
- modforge_cli/core/downloader.py +208 -0
- modforge_cli/core/models.py +66 -0
- modforge_cli/core/policy.py +161 -0
- modforge_cli/core/resolver.py +184 -0
- modforge_cli/core/utils.py +416 -0
- modforge_cli-0.2.3.dist-info/METADATA +70 -0
- modforge_cli-0.2.3.dist-info/RECORD +24 -0
- modforge_cli-0.2.3.dist-info/WHEEL +4 -0
- modforge_cli-0.2.3.dist-info/entry_points.txt +3 -0
- modforge_cli-0.2.3.dist-info/licenses/LICENSE +21 -0
modforge_cli/__init__.py
ADDED
modforge_cli/__main__.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main CLI entry point - Registers all commands
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from pyfiglet import figlet_format
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from modforge_cli.cli import export, modpack, project, setup, sklauncher, utils
|
|
13
|
+
from modforge_cli.cli.shared import (
|
|
14
|
+
DEFAULT_MODRINTH_API_URL,
|
|
15
|
+
DEFAULT_POLICY_URL,
|
|
16
|
+
MODRINTH_API,
|
|
17
|
+
POLICY_PATH,
|
|
18
|
+
console,
|
|
19
|
+
get_version_info,
|
|
20
|
+
)
|
|
21
|
+
from modforge_cli.core import ensure_config_file, setup_crash_logging
|
|
22
|
+
|
|
23
|
+
# Get version info
|
|
24
|
+
__version__, __author__ = get_version_info()
|
|
25
|
+
|
|
26
|
+
# Create main app
|
|
27
|
+
app = typer.Typer(
|
|
28
|
+
add_completion=False,
|
|
29
|
+
no_args_is_help=False,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Setup crash logging
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
LOG_DIR = setup_crash_logging()
|
|
36
|
+
|
|
37
|
+
# Ensure configs exist
|
|
38
|
+
ensure_config_file(MODRINTH_API, DEFAULT_MODRINTH_API_URL, "Modrinth API", console)
|
|
39
|
+
ensure_config_file(POLICY_PATH, DEFAULT_POLICY_URL, "Policy", console)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def render_banner() -> None:
|
|
43
|
+
"""Renders a stylized banner"""
|
|
44
|
+
width = console.width
|
|
45
|
+
font = "slant" if width > 60 else "small"
|
|
46
|
+
|
|
47
|
+
ascii_art = figlet_format("ModForge-CLI", font=font)
|
|
48
|
+
banner_text = Text(ascii_art, style="bold cyan")
|
|
49
|
+
|
|
50
|
+
info_line = Text.assemble(
|
|
51
|
+
(" ⛏ ", "yellow"),
|
|
52
|
+
(f"v{__version__}", "bold white"),
|
|
53
|
+
(" | ", "dim"),
|
|
54
|
+
("Created by ", "italic white"),
|
|
55
|
+
(f"{__author__}", "bold magenta"),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
console.print(
|
|
59
|
+
Panel(
|
|
60
|
+
Text.assemble(banner_text, "\n", info_line),
|
|
61
|
+
border_style="blue",
|
|
62
|
+
padding=(1, 2),
|
|
63
|
+
expand=False,
|
|
64
|
+
),
|
|
65
|
+
justify="left",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.callback(invoke_without_command=True)
|
|
70
|
+
def main_callback(
|
|
71
|
+
ctx: typer.Context,
|
|
72
|
+
version: bool | None = typer.Option(None, "--version", "-v", help="Show version and exit"),
|
|
73
|
+
verbose: bool | None = typer.Option(None, "--verbose", help="Enable verbose logging"),
|
|
74
|
+
) -> None:
|
|
75
|
+
"""ModForge-CLI: A powerful Minecraft modpack manager for Modrinth."""
|
|
76
|
+
|
|
77
|
+
if verbose:
|
|
78
|
+
# Enable verbose logging
|
|
79
|
+
logging.basicConfig(
|
|
80
|
+
level=logging.DEBUG,
|
|
81
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
82
|
+
handlers=[
|
|
83
|
+
logging.FileHandler(LOG_DIR / f"modforge-{__version__}.log"),
|
|
84
|
+
logging.StreamHandler(),
|
|
85
|
+
],
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if version:
|
|
89
|
+
console.print(f"ModForge-CLI Version: [bold cyan]{__version__}[/bold cyan]")
|
|
90
|
+
raise typer.Exit()
|
|
91
|
+
|
|
92
|
+
if ctx.invoked_subcommand is None:
|
|
93
|
+
render_banner()
|
|
94
|
+
console.print("\n[bold yellow]Usage:[/bold yellow] ModForge-CLI [COMMAND] [ARGS]...")
|
|
95
|
+
console.print("\n[bold cyan]Core Commands:[/bold cyan]")
|
|
96
|
+
console.print(" [green]setup[/green] Initialize a new modpack project")
|
|
97
|
+
console.print(" [green]ls[/green] List all registered projects")
|
|
98
|
+
console.print(" [green]add[/green] Add a mod/resource/shader to manifest")
|
|
99
|
+
console.print(" [green]resolve[/green] Resolve all dependencies")
|
|
100
|
+
console.print(" [green]build[/green] Download files and setup loader")
|
|
101
|
+
console.print(" [green]export[/green] Create the final .mrpack")
|
|
102
|
+
console.print(" [green]validate[/green] Check .mrpack for issues")
|
|
103
|
+
console.print(" [green]sklauncher[/green] Create SKLauncher profile (no .mrpack)")
|
|
104
|
+
console.print(" [green]remove[/green] Remove a modpack project")
|
|
105
|
+
console.print("\n[bold cyan]Utility:[/bold cyan]")
|
|
106
|
+
console.print(" [green]self-update[/green] Update ModForge-CLI")
|
|
107
|
+
console.print(" [green]doctor[/green] Validate installation")
|
|
108
|
+
console.print("\nRun [white]ModForge-CLI --help[/white] for details.\n")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# Register all command groups
|
|
112
|
+
app.command()(setup.setup)
|
|
113
|
+
app.add_typer(project.app, name="project", help="Project management commands")
|
|
114
|
+
app.command("ls")(project.list_projects)
|
|
115
|
+
app.command()(project.remove)
|
|
116
|
+
app.command()(modpack.add)
|
|
117
|
+
app.command()(modpack.resolve)
|
|
118
|
+
app.command()(modpack.build)
|
|
119
|
+
app.command()(export.export)
|
|
120
|
+
app.command()(export.validate)
|
|
121
|
+
app.command()(sklauncher.sklauncher)
|
|
122
|
+
app.command()(utils.doctor)
|
|
123
|
+
app.command("self-update")(utils.self_update_cmd)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def main() -> None:
|
|
127
|
+
"""Main entry point"""
|
|
128
|
+
app()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
main()
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""
|
|
2
|
+
api/modrith_api.py - Modrinth API v2 URL builder using modrinth_api.json
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
from urllib.parse import quote_plus
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ModrinthAPIConfig:
|
|
14
|
+
"""Loads modrinth_api.json and builds Modrinth API URLs."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, config_path: str | Path = "configs/modrinth_api.json"):
|
|
17
|
+
self.config_path = config_path if isinstance(config_path, Path) else Path(config_path)
|
|
18
|
+
self.base_url: str = ""
|
|
19
|
+
self.endpoints: Dict[str, Any] = {}
|
|
20
|
+
self._load_config()
|
|
21
|
+
|
|
22
|
+
def _load_config(self) -> None:
|
|
23
|
+
if not self.config_path.exists():
|
|
24
|
+
raise FileNotFoundError(f"Modrinth API config not found: {self.config_path}")
|
|
25
|
+
|
|
26
|
+
with open(self.config_path, "r", encoding="utf-8") as f:
|
|
27
|
+
data = json.load(f)
|
|
28
|
+
|
|
29
|
+
self.base_url = data.get("BASE_URL", "").rstrip("/")
|
|
30
|
+
if not self.base_url:
|
|
31
|
+
raise ValueError("BASE_URL missing in modrinth_api.json")
|
|
32
|
+
|
|
33
|
+
self.endpoints = data.get("ENDPOINTS", {})
|
|
34
|
+
if not isinstance(self.endpoints, Dict):
|
|
35
|
+
raise ValueError("ENDPOINTS section is invalid")
|
|
36
|
+
|
|
37
|
+
def build_url(self, template: str, **kwargs: str) -> str:
|
|
38
|
+
"""Format a template string with kwargs and prepend base URL."""
|
|
39
|
+
try:
|
|
40
|
+
path = template.format(**kwargs)
|
|
41
|
+
return f"{self.base_url}{path}"
|
|
42
|
+
except KeyError as e:
|
|
43
|
+
raise ValueError(f"Missing URL parameter: {e}")
|
|
44
|
+
|
|
45
|
+
# === Search ===
|
|
46
|
+
|
|
47
|
+
def search(
|
|
48
|
+
self,
|
|
49
|
+
query: Optional[str] = None,
|
|
50
|
+
facets: Optional[List[List[str]] | str] = None,
|
|
51
|
+
categories: Optional[List[str]] = None,
|
|
52
|
+
loaders: Optional[List[str]] = None,
|
|
53
|
+
game_versions: Optional[List[str]] = None,
|
|
54
|
+
license_: Optional[str] = None,
|
|
55
|
+
project_type: Optional[str] = None,
|
|
56
|
+
offset: Optional[int] = None,
|
|
57
|
+
limit: Optional[int] = 10,
|
|
58
|
+
index: Optional[str] = "relevance",
|
|
59
|
+
) -> str:
|
|
60
|
+
"""
|
|
61
|
+
Build the Modrinth search URL with query parameters.
|
|
62
|
+
|
|
63
|
+
Docs: https://docs.modrinth.com/api-spec#endpoints-search
|
|
64
|
+
|
|
65
|
+
Facets format: [[inner AND], [inner AND]] = outer OR
|
|
66
|
+
Example: [["categories:performance"], ["project_type:mod"]]
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
query: Search term (e.g., "sodium")
|
|
70
|
+
facets: Advanced filters as list of lists or JSON string
|
|
71
|
+
categories: Filter by categories (e.g., ["performance"])
|
|
72
|
+
loaders: Filter by loaders (e.g., ["fabric", "quilt"])
|
|
73
|
+
game_versions: Filter by Minecraft versions (e.g., ["1.21.1"])
|
|
74
|
+
license_: Filter by license (e.g., "MIT")
|
|
75
|
+
project_type: "mod", "resourcepack", "shader", "modpack", "datapack"
|
|
76
|
+
offset: Pagination offset
|
|
77
|
+
limit: Results per page (max 100)
|
|
78
|
+
index: Sort by "relevance", "downloads", "updated", "newest"
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Full search URL with query parameters
|
|
82
|
+
"""
|
|
83
|
+
base = self.build_url(self.endpoints["search"])
|
|
84
|
+
params = []
|
|
85
|
+
if query:
|
|
86
|
+
params.append(f"query={quote_plus(query)}")
|
|
87
|
+
|
|
88
|
+
facets_array = []
|
|
89
|
+
if facets:
|
|
90
|
+
if isinstance(facets, str):
|
|
91
|
+
params.append(f"facets={quote_plus(facets)}")
|
|
92
|
+
else:
|
|
93
|
+
facets_array.extend(facets)
|
|
94
|
+
|
|
95
|
+
if project_type:
|
|
96
|
+
facets_array.append([f"project_type:{project_type}"])
|
|
97
|
+
if categories:
|
|
98
|
+
[facets_array.append([f"categories:{c}"]) for c in categories]
|
|
99
|
+
if loaders:
|
|
100
|
+
facets_array.append([f"categories:{l}" for l in loaders])
|
|
101
|
+
if game_versions:
|
|
102
|
+
facets_array.append([f"versions:{v}" for v in game_versions])
|
|
103
|
+
if license_:
|
|
104
|
+
facets_array.append([f"license:{license_}"])
|
|
105
|
+
|
|
106
|
+
if facets_array and not (isinstance(facets, str)):
|
|
107
|
+
params.append(f"facets={quote_plus(json.dumps(facets_array))}")
|
|
108
|
+
|
|
109
|
+
if offset is not None:
|
|
110
|
+
params.append(f"offset={offset}")
|
|
111
|
+
if limit is not None:
|
|
112
|
+
params.append(f"limit={min(limit, 100)}")
|
|
113
|
+
if index:
|
|
114
|
+
params.append(f"index={index}")
|
|
115
|
+
|
|
116
|
+
return f"{base}?{'&'.join(params)}" if params else base
|
|
117
|
+
|
|
118
|
+
# === Projects ===
|
|
119
|
+
|
|
120
|
+
def project(self, project_id: str) -> str:
|
|
121
|
+
return self.build_url(self.endpoints["projects"]["project"], id=project_id)
|
|
122
|
+
|
|
123
|
+
def project_versions(self, project_id: str) -> str:
|
|
124
|
+
return self.build_url(self.endpoints["projects"]["project_versions"], id=project_id)
|
|
125
|
+
|
|
126
|
+
def project_dependencies(self, project_id: str) -> str:
|
|
127
|
+
return self.build_url(self.endpoints["projects"]["dependencies"], id=project_id)
|
|
128
|
+
|
|
129
|
+
def project_gallery(self, project_id: str) -> str:
|
|
130
|
+
return self.build_url(self.endpoints["projects"]["gallery"], id=project_id)
|
|
131
|
+
|
|
132
|
+
def project_icon(self, project_id: str) -> str:
|
|
133
|
+
return self.build_url(self.endpoints["projects"]["icon"], id=project_id)
|
|
134
|
+
|
|
135
|
+
def check_following(self, project_id: str) -> str:
|
|
136
|
+
return self.build_url(self.endpoints["projects"]["check_following"], id=project_id)
|
|
137
|
+
|
|
138
|
+
# === Versions ===
|
|
139
|
+
|
|
140
|
+
def version(self, version_id: str) -> str:
|
|
141
|
+
return self.build_url(self.endpoints["versions"]["version"], id=version_id)
|
|
142
|
+
|
|
143
|
+
def version_files(self, version_id: str) -> str:
|
|
144
|
+
return self.build_url(self.endpoints["versions"]["files"], id=version_id)
|
|
145
|
+
|
|
146
|
+
def version_file_download(self, version_id: str, filename: str) -> str:
|
|
147
|
+
return self.build_url(
|
|
148
|
+
self.endpoints["versions"]["download"], id=version_id, filename=filename
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def file_by_hash(self, hash_: str) -> str:
|
|
152
|
+
return self.build_url(self.endpoints["versions"]["file_by_hash"], hash=hash_)
|
|
153
|
+
|
|
154
|
+
def versions_by_hash(self, hash_: str) -> str:
|
|
155
|
+
return self.build_url(self.endpoints["versions"]["versions_by_hash"], hash=hash_)
|
|
156
|
+
|
|
157
|
+
def latest_version_for_hash(self, hash_: str, algorithm: str = "sha1") -> str:
|
|
158
|
+
return self.build_url(
|
|
159
|
+
self.endpoints["versions"]["latest_for_hash"], hash=hash_, algo=algorithm
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# === Tags ===
|
|
163
|
+
|
|
164
|
+
def categories(self) -> str:
|
|
165
|
+
return self.build_url(self.endpoints["tags"]["categories"])
|
|
166
|
+
|
|
167
|
+
def loaders(self) -> str:
|
|
168
|
+
return self.build_url(self.endpoints["tags"]["loaders"])
|
|
169
|
+
|
|
170
|
+
def game_versions(self) -> str:
|
|
171
|
+
return self.build_url(self.endpoints["tags"]["game_versions"])
|
|
172
|
+
|
|
173
|
+
def licenses(self) -> str:
|
|
174
|
+
return self.build_url(self.endpoints["tags"]["licenses"])
|
|
175
|
+
|
|
176
|
+
def environments(self) -> str:
|
|
177
|
+
return self.build_url(self.endpoints["tags"]["environments"])
|
|
178
|
+
|
|
179
|
+
# === Teams ===
|
|
180
|
+
|
|
181
|
+
def team(self, team_id: str) -> str:
|
|
182
|
+
return self.build_url(self.endpoints["teams"]["team"], id=team_id)
|
|
183
|
+
|
|
184
|
+
def team_members(self, team_id: str) -> str:
|
|
185
|
+
return self.build_url(self.endpoints["teams"]["members"], id=team_id)
|
|
186
|
+
|
|
187
|
+
# === User ===
|
|
188
|
+
|
|
189
|
+
def user(self, user_id: str) -> str:
|
|
190
|
+
return self.build_url(self.endpoints["user"]["user"], id=user_id)
|
|
191
|
+
|
|
192
|
+
def user_projects(self, user_id: str) -> str:
|
|
193
|
+
return self.build_url(self.endpoints["user"]["user_projects"], id=user_id)
|
|
194
|
+
|
|
195
|
+
def user_notifications(self, user_id: str) -> str:
|
|
196
|
+
return self.build_url(self.endpoints["user"]["notifications"], id=user_id)
|
|
197
|
+
|
|
198
|
+
def user_avatar(self, user_id: str) -> str:
|
|
199
|
+
return self.build_url(self.endpoints["user"]["avatar"], id=user_id)
|
|
200
|
+
|
|
201
|
+
# === Bulk ===
|
|
202
|
+
|
|
203
|
+
def bulk_projects(self) -> str:
|
|
204
|
+
return self.build_url(self.endpoints["bulk"]["projects"])
|
|
205
|
+
|
|
206
|
+
def bulk_versions(self) -> str:
|
|
207
|
+
return self.build_url(self.endpoints["bulk"]["versions"])
|
|
208
|
+
|
|
209
|
+
def bulk_version_files(self) -> str:
|
|
210
|
+
return self.build_url(self.endpoints["bulk"]["version_files"])
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Export and validation commands
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import shutil
|
|
8
|
+
import tempfile
|
|
9
|
+
import zipfile
|
|
10
|
+
from zipfile import ZIP_DEFLATED, ZipFile
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from modforge_cli.cli.shared import REGISTRY_PATH, console
|
|
15
|
+
from modforge_cli.core import get_manifest, load_registry
|
|
16
|
+
|
|
17
|
+
app = typer.Typer()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command()
|
|
21
|
+
def export(pack_name: str | None = None) -> None:
|
|
22
|
+
"""Create final .mrpack file"""
|
|
23
|
+
|
|
24
|
+
if not pack_name:
|
|
25
|
+
manifest = get_manifest(console, Path.cwd())
|
|
26
|
+
if manifest:
|
|
27
|
+
pack_name = manifest.name
|
|
28
|
+
else:
|
|
29
|
+
console.print("[red]No manifest found[/red]")
|
|
30
|
+
raise typer.Exit(1)
|
|
31
|
+
|
|
32
|
+
registry = load_registry(REGISTRY_PATH)
|
|
33
|
+
if pack_name not in registry:
|
|
34
|
+
console.print(f"[red]Pack '{pack_name}' not found[/red]")
|
|
35
|
+
raise typer.Exit(1)
|
|
36
|
+
|
|
37
|
+
pack_path = Path(registry[pack_name])
|
|
38
|
+
manifest = get_manifest(console, pack_path)
|
|
39
|
+
if not manifest:
|
|
40
|
+
raise typer.Exit(1)
|
|
41
|
+
|
|
42
|
+
console.print("[cyan]Exporting modpack...[/cyan]")
|
|
43
|
+
|
|
44
|
+
mods_dir = pack_path / "mods"
|
|
45
|
+
index_file = pack_path / "modrinth.index.json"
|
|
46
|
+
|
|
47
|
+
if not mods_dir.exists() or not any(mods_dir.iterdir()):
|
|
48
|
+
console.print("[red]No mods found. Run 'ModForge-CLI build' first[/red]")
|
|
49
|
+
raise typer.Exit(1)
|
|
50
|
+
|
|
51
|
+
if not index_file.exists():
|
|
52
|
+
console.print("[red]No modrinth.index.json found[/red]")
|
|
53
|
+
raise typer.Exit(1)
|
|
54
|
+
|
|
55
|
+
# Validate index has files
|
|
56
|
+
index_data = json.loads(index_file.read_text())
|
|
57
|
+
if not index_data.get("files"):
|
|
58
|
+
console.print("[yellow]Warning: No files registered in index[/yellow]")
|
|
59
|
+
console.print("[yellow]This might cause issues. Run 'ModForge-CLI build' again.[/yellow]")
|
|
60
|
+
|
|
61
|
+
# Create .mrpack
|
|
62
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
63
|
+
tmp_path = Path(tmpdir)
|
|
64
|
+
|
|
65
|
+
# Copy modrinth.index.json to root
|
|
66
|
+
shutil.copy2(index_file, tmp_path / "modrinth.index.json")
|
|
67
|
+
|
|
68
|
+
# Copy overrides if they exist
|
|
69
|
+
overrides_src = pack_path / "overrides"
|
|
70
|
+
if overrides_src.exists():
|
|
71
|
+
overrides_dst = tmp_path / "overrides"
|
|
72
|
+
shutil.copytree(overrides_src, overrides_dst)
|
|
73
|
+
console.print("[green]✓ Copied overrides[/green]")
|
|
74
|
+
|
|
75
|
+
# Create .mrpack
|
|
76
|
+
mrpack_path = pack_path.parent / f"{pack_name}.mrpack"
|
|
77
|
+
|
|
78
|
+
with ZipFile(mrpack_path, "w", ZIP_DEFLATED) as zipf:
|
|
79
|
+
# Add modrinth.index.json at root
|
|
80
|
+
zipf.write(tmp_path / "modrinth.index.json", "modrinth.index.json")
|
|
81
|
+
|
|
82
|
+
# Add overrides folder if exists
|
|
83
|
+
if overrides_src.exists():
|
|
84
|
+
for file_path in (tmp_path / "overrides").rglob("*"):
|
|
85
|
+
if file_path.is_file():
|
|
86
|
+
arcname = str(file_path.relative_to(tmp_path))
|
|
87
|
+
zipf.write(file_path, arcname)
|
|
88
|
+
|
|
89
|
+
console.print(f"[green bold]✓ Exported to {mrpack_path}[/green bold]")
|
|
90
|
+
|
|
91
|
+
# Show summary
|
|
92
|
+
file_count = len(index_data.get("files", []))
|
|
93
|
+
console.print("\n[cyan]Summary:[/cyan]")
|
|
94
|
+
console.print(f" Files registered: {file_count}")
|
|
95
|
+
console.print(f" Minecraft: {index_data['dependencies'].get('minecraft')}")
|
|
96
|
+
|
|
97
|
+
# Show loader
|
|
98
|
+
for loader in ["fabric-loader", "quilt-loader", "forge", "neoforge"]:
|
|
99
|
+
if loader in index_data["dependencies"]:
|
|
100
|
+
console.print(f" Loader: {loader} {index_data['dependencies'][loader]}")
|
|
101
|
+
|
|
102
|
+
console.print("\n[dim]Import this in SKLauncher, Prism, ATLauncher, etc.[/dim]")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command()
|
|
106
|
+
def validate(mrpack_file: str | None = None) -> None:
|
|
107
|
+
"""Validate .mrpack file for launcher compatibility"""
|
|
108
|
+
|
|
109
|
+
if not mrpack_file:
|
|
110
|
+
# Look for .mrpack in current directory
|
|
111
|
+
mrpacks = list(Path.cwd().glob("*.mrpack"))
|
|
112
|
+
if not mrpacks:
|
|
113
|
+
console.print("[red]No .mrpack file found in current directory[/red]")
|
|
114
|
+
console.print("[yellow]Usage: ModForge-CLI validate <file.mrpack>[/yellow]")
|
|
115
|
+
raise typer.Exit(1)
|
|
116
|
+
mrpack_path = mrpacks[0]
|
|
117
|
+
else:
|
|
118
|
+
mrpack_path = Path(mrpack_file)
|
|
119
|
+
|
|
120
|
+
if not mrpack_path.exists():
|
|
121
|
+
console.print(f"[red]File not found: {mrpack_path}[/red]")
|
|
122
|
+
raise typer.Exit(1)
|
|
123
|
+
|
|
124
|
+
console.print(f"[cyan]Validating {mrpack_path.name}...[/cyan]\n")
|
|
125
|
+
|
|
126
|
+
issues = []
|
|
127
|
+
warnings = []
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
with zipfile.ZipFile(mrpack_path, "r") as z:
|
|
131
|
+
files = z.namelist()
|
|
132
|
+
|
|
133
|
+
# Check for modrinth.index.json
|
|
134
|
+
if "modrinth.index.json" not in files:
|
|
135
|
+
console.print("[red]❌ CRITICAL: modrinth.index.json not found at root[/red]")
|
|
136
|
+
raise typer.Exit(1)
|
|
137
|
+
|
|
138
|
+
console.print("[green]✅ modrinth.index.json found[/green]")
|
|
139
|
+
|
|
140
|
+
# Read and validate index
|
|
141
|
+
index_data = json.loads(z.read("modrinth.index.json"))
|
|
142
|
+
|
|
143
|
+
# Check required fields
|
|
144
|
+
required = ["formatVersion", "game", "versionId", "name", "dependencies"]
|
|
145
|
+
for field in required:
|
|
146
|
+
if field not in index_data:
|
|
147
|
+
issues.append(f"Missing required field: {field}")
|
|
148
|
+
console.print(f"[red]❌ Missing: {field}[/red]")
|
|
149
|
+
else:
|
|
150
|
+
value = index_data[field]
|
|
151
|
+
if isinstance(value, dict):
|
|
152
|
+
console.print(f"[green]✅ {field}[/green]")
|
|
153
|
+
else:
|
|
154
|
+
console.print(f"[green]✅ {field}: {value}[/green]")
|
|
155
|
+
|
|
156
|
+
# Check dependencies
|
|
157
|
+
deps = index_data.get("dependencies", {})
|
|
158
|
+
if "minecraft" not in deps:
|
|
159
|
+
issues.append("Missing minecraft in dependencies")
|
|
160
|
+
console.print("[red]❌ Missing: minecraft version[/red]")
|
|
161
|
+
else:
|
|
162
|
+
console.print(f"[green]✅ Minecraft: {deps['minecraft']}[/green]")
|
|
163
|
+
|
|
164
|
+
# Check for loader
|
|
165
|
+
loaders = ["fabric-loader", "quilt-loader", "forge", "neoforge"]
|
|
166
|
+
has_loader = any(l in deps for l in loaders)
|
|
167
|
+
|
|
168
|
+
if not has_loader:
|
|
169
|
+
issues.append("No mod loader in dependencies")
|
|
170
|
+
console.print("[red]❌ Missing mod loader[/red]")
|
|
171
|
+
else:
|
|
172
|
+
for loader in loaders:
|
|
173
|
+
if loader in deps:
|
|
174
|
+
console.print(f"[green]✅ Loader: {loader} = {deps[loader]}[/green]")
|
|
175
|
+
|
|
176
|
+
# Check files array
|
|
177
|
+
files_list = index_data.get("files", [])
|
|
178
|
+
console.print(f"\n[cyan]📦 Files registered: {len(files_list)}[/cyan]")
|
|
179
|
+
|
|
180
|
+
if len(files_list) == 0:
|
|
181
|
+
warnings.append("No files in array (pack might not work)")
|
|
182
|
+
console.print("[yellow]⚠️ WARNING: files array is empty[/yellow]")
|
|
183
|
+
else:
|
|
184
|
+
# Check first file structure
|
|
185
|
+
sample = files_list[0]
|
|
186
|
+
file_required = ["path", "hashes", "downloads", "fileSize"]
|
|
187
|
+
|
|
188
|
+
missing_fields = [f for f in file_required if f not in sample]
|
|
189
|
+
if missing_fields:
|
|
190
|
+
issues.append(f"Files missing fields: {missing_fields}")
|
|
191
|
+
console.print(f"[red]❌ Files missing: {', '.join(missing_fields)}[/red]")
|
|
192
|
+
else:
|
|
193
|
+
console.print("[green]✅ File structure looks good[/green]")
|
|
194
|
+
|
|
195
|
+
# Check hashes
|
|
196
|
+
if "hashes" in sample:
|
|
197
|
+
if "sha1" not in sample["hashes"]:
|
|
198
|
+
issues.append("Files missing sha1 hash")
|
|
199
|
+
console.print("[red]❌ Missing sha1 hashes[/red]")
|
|
200
|
+
else:
|
|
201
|
+
console.print("[green]✅ sha1 hashes present[/green]")
|
|
202
|
+
|
|
203
|
+
if "sha512" not in sample["hashes"]:
|
|
204
|
+
warnings.append("Files missing sha512 hash")
|
|
205
|
+
console.print("[yellow]⚠️ Missing sha512 hashes (optional)[/yellow]")
|
|
206
|
+
else:
|
|
207
|
+
console.print("[green]✅ sha512 hashes present[/green]")
|
|
208
|
+
|
|
209
|
+
# Check env field
|
|
210
|
+
if "env" not in sample:
|
|
211
|
+
warnings.append("Files missing env field")
|
|
212
|
+
console.print("[yellow]⚠️ Missing env field (recommended)[/yellow]")
|
|
213
|
+
else:
|
|
214
|
+
console.print("[green]✅ env field present[/green]")
|
|
215
|
+
|
|
216
|
+
# Summary
|
|
217
|
+
console.print("\n" + "=" * 60)
|
|
218
|
+
|
|
219
|
+
if issues:
|
|
220
|
+
console.print(f"\n[red bold]❌ CRITICAL ISSUES ({len(issues)}):[/red bold]")
|
|
221
|
+
for issue in issues:
|
|
222
|
+
console.print(f" [red]• {issue}[/red]")
|
|
223
|
+
|
|
224
|
+
if warnings:
|
|
225
|
+
console.print(f"\n[yellow bold]⚠️ WARNINGS ({len(warnings)}):[/yellow bold]")
|
|
226
|
+
for warning in warnings:
|
|
227
|
+
console.print(f" [yellow]• {warning}[/yellow]")
|
|
228
|
+
|
|
229
|
+
if not issues and not warnings:
|
|
230
|
+
console.print("\n[green bold]✅ All checks passed![/green bold]")
|
|
231
|
+
console.print("[dim]Pack should work in all Modrinth-compatible launchers[/dim]")
|
|
232
|
+
elif not issues:
|
|
233
|
+
console.print("\n[green]✅ No critical issues[/green]")
|
|
234
|
+
console.print("[dim]Pack should work, but consider addressing warnings[/dim]")
|
|
235
|
+
else:
|
|
236
|
+
console.print("\n[red bold]❌ Pack has critical issues[/red bold]")
|
|
237
|
+
console.print("[yellow]Run 'ModForge-CLI build' again to fix[/yellow]")
|
|
238
|
+
raise typer.Exit(1)
|
|
239
|
+
|
|
240
|
+
except zipfile.BadZipFile:
|
|
241
|
+
console.print("[red]❌ ERROR: Not a valid ZIP/MRPACK file[/red]")
|
|
242
|
+
raise typer.Exit(1)
|
|
243
|
+
except json.JSONDecodeError as e:
|
|
244
|
+
console.print("[red]❌ ERROR: Invalid JSON in modrinth.index.json[/red]")
|
|
245
|
+
console.print(f"[dim]{e}[/dim]")
|
|
246
|
+
raise typer.Exit(1)
|