modforge-cli 0.2.1__tar.gz → 0.2.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modforge-cli
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: ModForge-CLI — a Modrinth-based Minecraft modpack builder
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "modforge-cli"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "ModForge-CLI — a Modrinth-based Minecraft modpack builder"
5
5
  authors = [{ name = "Frank1o3", email = "jahdy1o3@gmail.com" }]
6
6
  license = { text = "MIT" }
@@ -1,5 +1,5 @@
1
1
  """
2
2
  Auto-generated file. DO NOT EDIT.
3
3
  """
4
- __version__ = "0.2.1"
4
+ __version__ = "0.2.2"
5
5
  __author__ = "Frank1o3"
@@ -1,34 +1,34 @@
1
1
  import asyncio
2
2
  import json
3
- from pathlib import Path
4
3
  import shutil
5
- import subprocess
6
- import sys
7
- import urllib.request
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ import tempfile
8
+ from zipfile import ZipFile, ZIP_DEFLATED
8
9
 
10
+ import typer
9
11
  from pyfiglet import figlet_format
10
12
  from rich.console import Console
11
13
  from rich.panel import Panel
12
14
  from rich.prompt import Confirm
13
15
  from rich.table import Table
14
16
  from rich.text import Text
15
- import typer
16
17
 
17
18
  from modforge_cli.api import ModrinthAPIConfig
19
+ from modforge_cli.core import Manifest
20
+ from modforge_cli.core import ModPolicy, ModResolver
18
21
  from modforge_cli.core import (
19
- Manifest,
20
- ModPolicy,
21
- ModResolver,
22
- ensure_config_file,
23
- get_api_session,
24
- get_manifest,
22
+ self_update,
25
23
  install_fabric,
26
- load_registry,
24
+ get_manifest,
27
25
  perform_add,
26
+ ensure_config_file,
28
27
  run,
29
28
  save_registry_atomic,
30
- self_update,
29
+ load_registry,
31
30
  setup_crash_logging,
31
+ get_api_session,
32
32
  )
33
33
 
34
34
  # Import version info
@@ -113,7 +113,6 @@ def main_callback(
113
113
 
114
114
  if verbose:
115
115
  # Enable verbose logging
116
- import logging
117
116
 
118
117
  logging.basicConfig(
119
118
  level=logging.DEBUG,
@@ -132,16 +131,18 @@ def main_callback(
132
131
  render_banner()
133
132
  console.print("\n[bold yellow]Usage:[/bold yellow] ModForge-CLI [COMMAND] [ARGS]...")
134
133
  console.print("\n[bold cyan]Core Commands:[/bold cyan]")
135
- console.print(" [green]setup[/green] Initialize a new modpack project")
136
- console.print(" [green]ls[/green] List all registered projects")
137
- console.print(" [green]add[/green] Add a mod/resource/shader to manifest")
138
- console.print(" [green]resolve[/green] Resolve all dependencies")
139
- console.print(" [green]build[/green] Download files and setup loader")
140
- console.print(" [green]export[/green] Create the final .mrpack")
141
- console.print(" [green]remove[/green] Remove a modpack project")
134
+ console.print(" [green]setup[/green] Initialize a new modpack project")
135
+ console.print(" [green]ls[/green] List all registered projects")
136
+ console.print(" [green]add[/green] Add a mod/resource/shader to manifest")
137
+ console.print(" [green]resolve[/green] Resolve all dependencies")
138
+ console.print(" [green]build[/green] Download files and setup loader")
139
+ console.print(" [green]export[/green] Create the final .mrpack")
140
+ console.print(" [green]validate[/green] Check .mrpack for issues")
141
+ console.print(" [green]sklauncher[/green] Create SKLauncher profile (no .mrpack)")
142
+ console.print(" [green]remove[/green] Remove a modpack project")
142
143
  console.print("\n[bold cyan]Utility:[/bold cyan]")
143
- console.print(" [green]self-update[/green] Update ModForge-CLI")
144
- console.print(" [green]doctor[/green] Validate installation")
144
+ console.print(" [green]self-update[/green] Update ModForge-CLI")
145
+ console.print(" [green]doctor[/green] Validate installation")
145
146
  console.print("\nRun [white]ModForge-CLI --help[/white] for details.\n")
146
147
 
147
148
 
@@ -186,12 +187,13 @@ def setup(
186
187
  }
187
188
  loader_key = loader_key_map.get(loader.lower(), loader.lower())
188
189
 
190
+ # SKLauncher requires exact format - dependencies MUST have loader first
189
191
  index_data = {
190
192
  "formatVersion": 1,
191
193
  "game": "minecraft",
192
194
  "versionId": "1.0.0",
193
195
  "name": name,
194
- "files": [], # Will be populated during build
196
+ "files": [],
195
197
  "dependencies": {loader_key: loader_version, "minecraft": mc},
196
198
  }
197
199
  (pack_dir / "modrinth.index.json").write_text(json.dumps(index_data, indent=2))
@@ -352,61 +354,71 @@ def export(pack_name: str | None = None) -> None:
352
354
  if not manifest:
353
355
  raise typer.Exit(1)
354
356
 
355
- loader_version = manifest.loader_version or FABRIC_LOADER_VERSION
356
-
357
357
  console.print("[cyan]Exporting modpack...[/cyan]")
358
358
 
359
359
  mods_dir = pack_path / "mods"
360
+ index_file = pack_path / "modrinth.index.json"
361
+
360
362
  if not mods_dir.exists() or not any(mods_dir.iterdir()):
361
363
  console.print("[red]No mods found. Run 'ModForge-CLI build' first[/red]")
362
364
  raise typer.Exit(1)
363
365
 
364
- # Install loader if needed
365
- if manifest.loader == "fabric":
366
- installer = pack_path / ".fabric-installer.jar"
367
-
368
- if not installer.exists():
369
- console.print("[yellow]Downloading Fabric installer...[/yellow]")
370
-
371
- urllib.request.urlretrieve(FABRIC_INSTALLER_URL, installer)
372
-
373
- # Verify hash (security)
374
- # Note: Update FABRIC_INSTALLER_SHA256 with actual hash
375
- # actual_hash = hashlib.sha256(installer.read_bytes()).hexdigest()
376
- # if actual_hash != FABRIC_INSTALLER_SHA256:
377
- # console.print("[red]Installer hash mismatch![/red]")
378
- # installer.unlink()
379
- # raise typer.Exit(1)
380
-
381
- console.print("[yellow]Installing Fabric...[/yellow]")
382
- try:
383
- install_fabric(
384
- installer=installer,
385
- mc_version=manifest.minecraft,
386
- loader_version=loader_version,
387
- game_dir=pack_path,
388
- )
389
- console.print(f"[green]✓ Fabric {loader_version} installed[/green]")
390
- except RuntimeError as e:
391
- console.print(f"[red]{e}[/red]")
392
- raise typer.Exit(1) from e
393
-
394
- installer.unlink(missing_ok=True)
395
-
396
- # Create .mrpack
397
- zip_path = pack_path.parent / f"{pack_name}.mrpack"
398
- shutil.make_archive(
399
- base_name=str(zip_path.with_suffix("")),
400
- format="zip",
401
- root_dir=pack_path,
402
- )
366
+ if not index_file.exists():
367
+ console.print("[red]No modrinth.index.json found[/red]")
368
+ raise typer.Exit(1)
369
+
370
+ # Validate index has files
371
+ index_data = json.loads(index_file.read_text())
372
+ if not index_data.get("files"):
373
+ console.print("[yellow]Warning: No files registered in index[/yellow]")
374
+ console.print("[yellow]This might cause issues. Run 'ModForge-CLI build' again.[/yellow]")
375
+
376
+ # Create .mrpack (which is just a renamed .zip)
377
+
378
+ # Create temp directory for packing
379
+ with tempfile.TemporaryDirectory() as tmpdir:
380
+ tmp_path = Path(tmpdir)
403
381
 
404
- # Rename .zip to .mrpack
405
- zip_file = pack_path.parent / f"{pack_name}.zip"
406
- if zip_file.exists():
407
- zip_file.rename(zip_path)
382
+ # Copy modrinth.index.json to root
383
+ import shutil
408
384
 
409
- console.print(f"[green bold]✓ Exported to {zip_path}[/green bold]")
385
+ shutil.copy2(index_file, tmp_path / "modrinth.index.json")
386
+
387
+ # Copy overrides if they exist
388
+ overrides_src = pack_path / "overrides"
389
+ if overrides_src.exists():
390
+ overrides_dst = tmp_path / "overrides"
391
+ shutil.copytree(overrides_src, overrides_dst)
392
+ console.print("[green]✓ Copied overrides[/green]")
393
+
394
+ # Create .mrpack
395
+ mrpack_path = pack_path.parent / f"{pack_name}.mrpack"
396
+
397
+ with ZipFile(mrpack_path, "w", ZIP_DEFLATED) as zipf:
398
+ # Add modrinth.index.json at root
399
+ zipf.write(tmp_path / "modrinth.index.json", "modrinth.index.json")
400
+
401
+ # Add overrides folder if exists
402
+ if overrides_src.exists():
403
+ for file_path in (tmp_path / "overrides").rglob("*"):
404
+ if file_path.is_file():
405
+ arcname = str(file_path.relative_to(tmp_path))
406
+ zipf.write(file_path, arcname)
407
+
408
+ console.print(f"[green bold]✓ Exported to {mrpack_path}[/green bold]")
409
+
410
+ # Show summary
411
+ file_count = len(index_data.get("files", []))
412
+ console.print("\n[cyan]Summary:[/cyan]")
413
+ console.print(f" Files registered: {file_count}")
414
+ console.print(f" Minecraft: {index_data['dependencies'].get('minecraft')}")
415
+
416
+ # Show loader
417
+ for loader in ["fabric-loader", "quilt-loader", "forge", "neoforge"]:
418
+ if loader in index_data["dependencies"]:
419
+ console.print(f" Loader: {loader} {index_data['dependencies'][loader]}")
420
+
421
+ console.print("\n[dim]Import this in SKLauncher, Prism, ATLauncher, etc.[/dim]")
410
422
 
411
423
 
412
424
  @app.command()
@@ -473,6 +485,7 @@ def doctor() -> None:
473
485
  issues = []
474
486
 
475
487
  # Check Python version
488
+ import sys
476
489
 
477
490
  py_version = f"{sys.version_info.major}.{sys.version_info.minor}"
478
491
  console.print(f"[green]✓[/green] Python {py_version}")
@@ -491,6 +504,8 @@ def doctor() -> None:
491
504
 
492
505
  # Check Java
493
506
  try:
507
+ import subprocess
508
+
494
509
  result = subprocess.run(["java", "-version"], capture_output=True, text=True, check=True)
495
510
  console.print("[green]✓[/green] Java installed")
496
511
  except (FileNotFoundError, subprocess.CalledProcessError):
@@ -517,6 +532,265 @@ def self_update_cmd() -> None:
517
532
  raise typer.Exit(1) from e
518
533
 
519
534
 
535
+ @app.command()
536
+ def validate(mrpack_file: str | None = None) -> None:
537
+ """Validate .mrpack file for launcher compatibility"""
538
+
539
+ if not mrpack_file:
540
+ # Look for .mrpack in current directory
541
+ mrpacks = list(Path.cwd().glob("*.mrpack"))
542
+ if not mrpacks:
543
+ console.print("[red]No .mrpack file found in current directory[/red]")
544
+ console.print("[yellow]Usage: ModForge-CLI validate <file.mrpack>[/yellow]")
545
+ raise typer.Exit(1)
546
+ mrpack_path = mrpacks[0]
547
+ else:
548
+ mrpack_path = Path(mrpack_file)
549
+
550
+ if not mrpack_path.exists():
551
+ console.print(f"[red]File not found: {mrpack_path}[/red]")
552
+ raise typer.Exit(1)
553
+
554
+ console.print(f"[cyan]Validating {mrpack_path.name}...[/cyan]\n")
555
+
556
+ import zipfile
557
+
558
+ issues = []
559
+ warnings = []
560
+
561
+ try:
562
+ with zipfile.ZipFile(mrpack_path, "r") as z:
563
+ files = z.namelist()
564
+
565
+ # Check for modrinth.index.json
566
+ if "modrinth.index.json" not in files:
567
+ console.print("[red]❌ CRITICAL: modrinth.index.json not found at root[/red]")
568
+ raise typer.Exit(1)
569
+
570
+ console.print("[green]✅ modrinth.index.json found[/green]")
571
+
572
+ # Read and validate index
573
+ index_data = json.loads(z.read("modrinth.index.json"))
574
+
575
+ # Check required fields
576
+ required = ["formatVersion", "game", "versionId", "name", "dependencies"]
577
+ for field in required:
578
+ if field not in index_data:
579
+ issues.append(f"Missing required field: {field}")
580
+ console.print(f"[red]❌ Missing: {field}[/red]")
581
+ else:
582
+ value = index_data[field]
583
+ if isinstance(value, dict):
584
+ console.print(f"[green]✅ {field}[/green]")
585
+ else:
586
+ console.print(f"[green]✅ {field}: {value}[/green]")
587
+
588
+ # Check dependencies
589
+ deps = index_data.get("dependencies", {})
590
+ if "minecraft" not in deps:
591
+ issues.append("Missing minecraft in dependencies")
592
+ console.print("[red]❌ Missing: minecraft version[/red]")
593
+ else:
594
+ console.print(f"[green]✅ Minecraft: {deps['minecraft']}[/green]")
595
+
596
+ # Check for loader
597
+ loaders = ["fabric-loader", "quilt-loader", "forge", "neoforge"]
598
+ has_loader = any(l in deps for l in loaders)
599
+
600
+ if not has_loader:
601
+ issues.append("No mod loader in dependencies")
602
+ console.print("[red]❌ Missing mod loader[/red]")
603
+ else:
604
+ for loader in loaders:
605
+ if loader in deps:
606
+ console.print(f"[green]✅ Loader: {loader} = {deps[loader]}[/green]")
607
+
608
+ # Check files array
609
+ files_list = index_data.get("files", [])
610
+ console.print(f"\n[cyan]📦 Files registered: {len(files_list)}[/cyan]")
611
+
612
+ if len(files_list) == 0:
613
+ warnings.append("No files in array (pack might not work)")
614
+ console.print("[yellow]⚠️ WARNING: files array is empty[/yellow]")
615
+ else:
616
+ # Check first file structure
617
+ sample = files_list[0]
618
+ file_required = ["path", "hashes", "downloads", "fileSize"]
619
+
620
+ missing_fields = [f for f in file_required if f not in sample]
621
+ if missing_fields:
622
+ issues.append(f"Files missing fields: {missing_fields}")
623
+ console.print(f"[red]❌ Files missing: {', '.join(missing_fields)}[/red]")
624
+ else:
625
+ console.print("[green]✅ File structure looks good[/green]")
626
+
627
+ # Check hashes
628
+ if "hashes" in sample:
629
+ if "sha1" not in sample["hashes"]:
630
+ issues.append("Files missing sha1 hash")
631
+ console.print("[red]❌ Missing sha1 hashes[/red]")
632
+ else:
633
+ console.print("[green]✅ sha1 hashes present[/green]")
634
+
635
+ if "sha512" not in sample["hashes"]:
636
+ warnings.append("Files missing sha512 hash")
637
+ console.print("[yellow]⚠️ Missing sha512 hashes (optional)[/yellow]")
638
+ else:
639
+ console.print("[green]✅ sha512 hashes present[/green]")
640
+
641
+ # Check env field
642
+ if "env" not in sample:
643
+ warnings.append("Files missing env field")
644
+ console.print("[yellow]⚠️ Missing env field (recommended)[/yellow]")
645
+ else:
646
+ console.print("[green]✅ env field present[/green]")
647
+
648
+ # Summary
649
+ console.print("\n" + "=" * 60)
650
+
651
+ if issues:
652
+ console.print(f"\n[red bold]❌ CRITICAL ISSUES ({len(issues)}):[/red bold]")
653
+ for issue in issues:
654
+ console.print(f" [red]• {issue}[/red]")
655
+
656
+ if warnings:
657
+ console.print(f"\n[yellow bold]⚠️ WARNINGS ({len(warnings)}):[/yellow bold]")
658
+ for warning in warnings:
659
+ console.print(f" [yellow]• {warning}[/yellow]")
660
+
661
+ if not issues and not warnings:
662
+ console.print("\n[green bold]✅ All checks passed![/green bold]")
663
+ console.print("[dim]Pack should work in all Modrinth-compatible launchers[/dim]")
664
+ elif not issues:
665
+ console.print("\n[green]✅ No critical issues[/green]")
666
+ console.print("[dim]Pack should work, but consider addressing warnings[/dim]")
667
+ else:
668
+ console.print("\n[red bold]❌ Pack has critical issues[/red bold]")
669
+ console.print("[yellow]Run 'ModForge-CLI build' again to fix[/yellow]")
670
+ raise typer.Exit(1)
671
+
672
+ except zipfile.BadZipFile:
673
+ console.print("[red]❌ ERROR: Not a valid ZIP/MRPACK file[/red]")
674
+ raise typer.Exit(1) from e
675
+ except json.JSONDecodeError as e:
676
+ console.print("[red]❌ ERROR: Invalid JSON in modrinth.index.json[/red]")
677
+ console.print(f"[dim]{e}[/dim]")
678
+ raise typer.Exit(1) from e
679
+
680
+
681
+ @app.command()
682
+ def sklauncher(pack_name: str | None = None, profile_name: str | None = None) -> None:
683
+ """Create SKLauncher-compatible profile (alternative to export)"""
684
+
685
+ if not pack_name:
686
+ manifest = get_manifest(console, Path.cwd())
687
+ if manifest:
688
+ pack_name = manifest.name
689
+ else:
690
+ console.print("[red]No manifest found[/red]")
691
+ raise typer.Exit(1)
692
+
693
+ registry = load_registry(REGISTRY_PATH)
694
+ if pack_name not in registry:
695
+ console.print(f"[red]Pack '{pack_name}' not found[/red]")
696
+ raise typer.Exit(1)
697
+
698
+ pack_path = Path(registry[pack_name])
699
+ manifest = get_manifest(console, pack_path)
700
+ if not manifest:
701
+ raise typer.Exit(1)
702
+
703
+ # Check if mods are built
704
+ mods_dir = pack_path / "mods"
705
+ if not mods_dir.exists() or not any(mods_dir.iterdir()):
706
+ console.print("[red]No mods found. Run 'ModForge-CLI build' first[/red]")
707
+ raise typer.Exit(1)
708
+
709
+ # Get Minecraft directory
710
+ import platform
711
+
712
+ if platform.system() == "Windows":
713
+ minecraft_dir = Path.home() / "AppData" / "Roaming" / ".minecraft"
714
+ elif platform.system() == "Darwin":
715
+ minecraft_dir = Path.home() / "Library" / "Application Support" / "minecraft"
716
+ else:
717
+ minecraft_dir = Path.home() / ".minecraft"
718
+
719
+ if not minecraft_dir.exists():
720
+ console.print(f"[red]Minecraft directory not found: {minecraft_dir}[/red]")
721
+ raise typer.Exit(1)
722
+
723
+ # Use pack name if profile name not specified
724
+ if not profile_name:
725
+ profile_name = pack_name
726
+
727
+ console.print(f"[cyan]Creating SKLauncher profile '{profile_name}'...[/cyan]")
728
+
729
+ # Create instance directory
730
+ instance_dir = minecraft_dir / "instances" / profile_name
731
+ instance_dir.mkdir(parents=True, exist_ok=True)
732
+
733
+ # Copy mods
734
+ dst_mods = instance_dir / "mods"
735
+ if dst_mods.exists():
736
+ shutil.rmtree(dst_mods)
737
+ shutil.copytree(mods_dir, dst_mods)
738
+ mod_count = len(list(dst_mods.glob("*.jar")))
739
+ console.print(f"[green]✓ Copied {mod_count} mods[/green]")
740
+
741
+ # Copy overrides
742
+ overrides_src = pack_path / "overrides"
743
+ if overrides_src.exists():
744
+ for item in overrides_src.iterdir():
745
+ dst = instance_dir / item.name
746
+ if item.is_dir():
747
+ if dst.exists():
748
+ shutil.rmtree(dst)
749
+ shutil.copytree(item, dst)
750
+ else:
751
+ shutil.copy2(item, dst)
752
+ console.print("[green]✓ Copied overrides[/green]")
753
+
754
+ # Update launcher_profiles.json
755
+ profiles_file = minecraft_dir / "launcher_profiles.json"
756
+
757
+ if profiles_file.exists():
758
+ profiles_data = json.loads(profiles_file.read_text())
759
+ else:
760
+ profiles_data = {"profiles": {}, "settings": {}, "version": 3}
761
+
762
+ # Create profile entry
763
+ from datetime import datetime
764
+
765
+ profile_id = profile_name.lower().replace(" ", "_").replace("-", "_")
766
+ loader_version = manifest.loader_version or FABRIC_LOADER_VERSION
767
+
768
+ profiles_data["profiles"][profile_id] = {
769
+ "name": profile_name,
770
+ "type": "custom",
771
+ "created": datetime.now().isoformat() + "Z",
772
+ "lastUsed": datetime.now().isoformat() + "Z",
773
+ "icon": "Furnace_On",
774
+ "lastVersionId": f"fabric-loader-{loader_version}-{manifest.minecraft}",
775
+ "gameDir": str(instance_dir),
776
+ }
777
+
778
+ # Save profiles
779
+ profiles_file.write_text(json.dumps(profiles_data, indent=2))
780
+
781
+ console.print("\n[green bold]✓ SKLauncher profile created![/green bold]")
782
+ console.print(f"\n[cyan]Profile:[/cyan] {profile_name}")
783
+ console.print(f"[cyan]Location:[/cyan] {instance_dir}")
784
+ console.print(f"[cyan]Version:[/cyan] fabric-loader-{loader_version}-{manifest.minecraft}")
785
+ console.print("\n[yellow]Next steps:[/yellow]")
786
+ console.print(" 1. Close SKLauncher if it's open")
787
+ console.print(" 2. Restart SKLauncher")
788
+ console.print(f" 3. Select profile '{profile_name}'")
789
+ console.print(" 4. If Fabric isn't installed, install it from SKLauncher:")
790
+ console.print(f" - MC: {manifest.minecraft}")
791
+ console.print(f" - Fabric: {loader_version}")
792
+
793
+
520
794
  def main() -> None:
521
795
  app()
522
796
 
@@ -68,7 +68,7 @@ class ModDownloader:
68
68
  # Prioritize by version type: release > beta > alpha
69
69
  version_priority = {"release": 3, "beta": 2, "alpha": 1}
70
70
 
71
- def version_score(v):
71
+ def version_score(v) -> int:
72
72
  vtype = v.get("version_type", "alpha")
73
73
  return version_priority.get(vtype, 0)
74
74
 
@@ -155,13 +155,12 @@ class ModDownloader:
155
155
  None,
156
156
  )
157
157
 
158
- if existing_entry:
158
+ if existing_entry and dest.exists():
159
159
  # Verify hash matches
160
- if dest.exists():
161
- existing_hash = hashlib.sha1(dest.read_bytes()).hexdigest()
162
- if existing_hash == primary_file["hashes"]["sha1"]:
163
- console.print(f"[dim]✓ {primary_file['filename']} (cached)[/dim]")
164
- return
160
+ existing_hash = hashlib.sha1(dest.read_bytes()).hexdigest()
161
+ if existing_hash == primary_file["hashes"]["sha1"]:
162
+ console.print(f"[dim]✓ {primary_file['filename']} (cached)[/dim]")
163
+ return
165
164
 
166
165
  # Download the file
167
166
  try:
@@ -193,7 +192,6 @@ class ModDownloader:
193
192
  file_entry = {
194
193
  "path": f"mods/{primary_file['filename']}",
195
194
  "hashes": {"sha1": sha1, "sha512": sha512},
196
- "env": {"client": "required", "server": "required"},
197
195
  "downloads": [primary_file["url"]],
198
196
  "fileSize": primary_file["size"],
199
197
  }
File without changes
File without changes