From 8651b076d320608b67879feb2641fd18179f2c77 Mon Sep 17 00:00:00 2001 From: claude-timemachine Date: Tue, 2 Jun 2026 11:44:20 +0200 Subject: [PATCH] =?UTF-8?q?initial:=20packwiz=20=E2=86=92=20simple-mod-syn?= =?UTF-8?q?c=20manifest=20converter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads packwiz pack (pack.toml + index.toml + per-file .pw.toml) and emits a simple-mod-sync sync_version=3 manifest. Drops server-only mods, skips CurseForge metadata-mode entries with a warning, maps content type from the parent directory of each metafile. Optional --bundle-non-mods zips config/, options.txt etc into one archive served as a 'packed' entry — covers the gap where simple-mod-sync only ships zip-extractable content for non-mods. 15 tests, includes integration against upstream packwiz-example-pack. --- .gitignore | 7 + LICENSE | 21 ++ README.md | 81 +++++ packwiz_to_sms.py | 291 ++++++++++++++++++ .../config/voicechat-client.properties | 2 + tests/fixture-pack/index.toml | 39 +++ tests/fixture-pack/mods/cf-only-mod.pw.toml | 13 + tests/fixture-pack/mods/fabric-api.pw.toml | 8 + .../mods/server-side-tool.pw.toml | 8 + tests/fixture-pack/mods/sodium.pw.toml | 13 + tests/fixture-pack/options.txt | 2 + tests/fixture-pack/pack.toml | 13 + .../resourcepacks/branding.pw.toml | 8 + tests/fixture-pack/shaderpacks/iris.pw.toml | 8 + tests/test_packwiz_to_sms.py | 194 ++++++++++++ 15 files changed, 708 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 packwiz_to_sms.py create mode 100644 tests/fixture-pack/config/voicechat-client.properties create mode 100644 tests/fixture-pack/index.toml create mode 100644 tests/fixture-pack/mods/cf-only-mod.pw.toml create mode 100644 tests/fixture-pack/mods/fabric-api.pw.toml create mode 100644 tests/fixture-pack/mods/server-side-tool.pw.toml create mode 100644 tests/fixture-pack/mods/sodium.pw.toml create mode 100644 tests/fixture-pack/options.txt create mode 100644 tests/fixture-pack/pack.toml create mode 100644 tests/fixture-pack/resourcepacks/branding.pw.toml create mode 100644 tests/fixture-pack/shaderpacks/iris.pw.toml create mode 100644 tests/test_packwiz_to_sms.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a8e46af --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +.pytest_cache/ +.venv/ +*.egg-info/ +build/ +dist/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b4ac81f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Timemachine + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..21eac27 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# packwiz-to-sms + +Convert a [packwiz](https://github.com/packwiz/packwiz) pack to a [simple-mod-sync](https://github.com/oxydien/simple-mod-sync) manifest (`sync_version: 3`). + +## Why + +Use **packwiz** to author the modpack (Modrinth/CurseForge integration, git-friendly TOML, optional/side-aware mods, `.mrpack` export for free), and **simple-mod-sync** for delivery to clients that can't or won't use Prism/MMC pre-launch hooks (vanilla launcher, TLauncher, cracked players). + +One source of truth, two distribution channels — `.mrpack` export and simple-mod-sync manifest are both produced from the same packwiz repo. + +## Install + +Requires Python 3.11+ (uses `tomllib`). Zero runtime deps. + +```bash +git clone https://git.timemachine.center/Timemachine/packwiz-to-sms.git +cd packwiz-to-sms +python3 packwiz_to_sms.py --help +``` + +## Usage + +```bash +# Minimal — emit manifest to stdout +python3 packwiz_to_sms.py /path/to/packwiz/pack + +# Write to file +python3 packwiz_to_sms.py /path/to/packwiz/pack -o manifest.json + +# Bundle non-mod files (config/, options.txt, servers.dat) into a zip +# and add a 'packed' entry pointing at where you'll host it +python3 packwiz_to_sms.py /path/to/packwiz/pack \ + -o manifest.json \ + --bundle-non-mods overrides.zip \ + --bundle-url https://packs.example.com/overrides.zip +``` + +## What gets emitted + +| Packwiz path | Becomes simple-mod-sync `type` | +|---|---| +| `mods/*.pw.toml` (side=client or both) | `mod` | +| `mods/*.pw.toml` (side=server) | dropped | +| `resourcepacks/*.pw.toml` | `resourcepack` | +| `shaderpacks/*.pw.toml` | `shader` | +| `**/datapacks/*.pw.toml` | `datapack` | +| Any non-metafile (e.g. `options.txt`, `config/*`) | bundled into zip via `--bundle-non-mods`, emitted as one `packed` entry pointing at `directory: "."` | + +CurseForge mods that use `mode = "metadata:curseforge"` (no direct URL) are skipped with a warning. Either switch to Modrinth equivalents or run `packwiz cf reexport` first to inline resolved URLs. + +## What's not handled + +- **Optional mods** — simple-mod-sync has no per-client toggle UI. All non-server mods are emitted unconditionally. Ship two manifests (with/without optional) if you need this. +- **Mod removal** — simple-mod-sync's `modify`/`remove` is not auto-populated. Convert by hand if you're dropping mods. +- **Rename / regex transforms** — packwiz has no equivalent concept, so we don't generate `modify.rename` entries. + +## How it works + +1. Reads `pack.toml` + `index.toml`. +2. For each entry marked `metafile = true`: reads the `.pw.toml`, pulls `download.url`, picks the simple-mod-sync `type` from the parent directory. +3. Drops `side = "server"`. +4. Drops entries where `download.url` is missing (CF metadata mode). +5. Optionally zips non-metafile files into a single archive for `packed` distribution. + +Output schema matches `sync_version: 3` exactly (see [simple-mod-sync DOCS.md](https://github.com/oxydien/simple-mod-sync/blob/main/DOCS.md)). + +## Tests + +```bash +python3 -m pytest tests/ +``` + +Covers conversion logic + CLI + bundle pipeline + a network integration test that converts the official [`packwiz-example-pack`](https://github.com/packwiz/packwiz-example-pack) (auto-skipped if offline). + +## Upstream contribution + +This tool fits the model used by other [simple-mod-sync translators](https://github.com/oxydien/simple-mod-sync/tree/main/translators). It can be PR'd upstream as `translators/packwiz.py`. + +## License + +MIT. diff --git a/packwiz_to_sms.py b/packwiz_to_sms.py new file mode 100644 index 0000000..0970006 --- /dev/null +++ b/packwiz_to_sms.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +"""Convert a packwiz pack into a simple-mod-sync manifest. + +Reads a packwiz pack rooted at PACK_DIR and emits a simple-mod-sync +sync_version=3 manifest to stdout (or --output). + +Mods, resourcepacks, shaders and datapacks are emitted with their direct +download URL pulled from each per-file packwiz metafile (.pw.toml). + +Server-only mods are dropped. Client-only and both-side mods are kept. + +CurseForge mods that lack a direct download URL (mode=metadata:curseforge) +are skipped with a warning; the operator can either swap to Modrinth or +pre-resolve URLs with `packwiz cf reexport`. + +Arbitrary files (config/, options.txt, servers.dat, ...) are not emitted by +default since simple-mod-sync's `packed`/`config` types expect a zip. +Pass --bundle-non-mods PATH to write a zip of all non-metafile files; a +single `packed` entry pointing at / will be appended +to the manifest. Operator is responsible for hosting that zip. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import tomllib +import zipfile +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + + +# --- packwiz model -------------------------------------------------------- + + +@dataclass(frozen=True) +class PackwizMetafile: + """One mods/foo.pw.toml file (mod or resourcepack/shader/datapack).""" + + path: Path # e.g. "mods/sodium.pw.toml" + name: str + filename: str + side: str # "client", "server", "both" + url: str | None # None when packwiz can't resolve (CF metadata mode) + hash_value: str | None + hash_format: str | None + + +def _load_pack_metafile(pack_root: Path, rel_path: str) -> PackwizMetafile: + abs_path = pack_root / rel_path + data = tomllib.loads(abs_path.read_text(encoding="utf-8")) + + download = data.get("download", {}) + mode = download.get("mode", "url") + url = download.get("url") if mode in ("url", "") else None + + return PackwizMetafile( + path=Path(rel_path), + name=data.get("name", abs_path.stem), + filename=data.get("filename", ""), + side=data.get("side", "both"), + url=url, + hash_value=download.get("hash"), + hash_format=download.get("hash-format"), + ) + + +def _load_index(pack_root: Path) -> dict[str, Any]: + return tomllib.loads((pack_root / "index.toml").read_text(encoding="utf-8")) + + +def _load_pack(pack_root: Path) -> dict[str, Any]: + return tomllib.loads((pack_root / "pack.toml").read_text(encoding="utf-8")) + + +# --- simple-mod-sync model ------------------------------------------------ + + +_DIR_TO_TYPE = { + "mods": "mod", + "resourcepacks": "resourcepack", + "shaderpacks": "shader", +} + + +def _content_type_for(rel_path: Path) -> str | None: + """Map mods/foo.pw.toml -> "mod", resourcepacks/foo.pw.toml -> "resourcepack" etc. + + `datapack` is intentionally not auto-mapped: packwiz puts datapacks under + a world directory which varies. Operator should set type manually or + place datapacks in a `datapacks/` top-level dir (uncommon). + """ + if rel_path.parent.name == "datapacks": + return "datapack" + return _DIR_TO_TYPE.get(rel_path.parts[0]) if rel_path.parts else None + + +def _make_entry(meta: PackwizMetafile, content_type: str) -> dict[str, Any]: + version = meta.filename or "unknown" + # packwiz filenames carry the version (e.g. sodium-0.6.5+1.21.1.jar). + # If we can extract a cleaner version, do it; otherwise keep the filename. + if meta.filename: + m = re.search(r"[-_]([\w.+]+?)\.(?:jar|zip)$", meta.filename) + if m: + version = m.group(1) + return { + "url": meta.url, + "name": meta.name, + "version": version, + "type": content_type, + } + + +# --- conversion driver ---------------------------------------------------- + + +@dataclass +class ConvertResult: + manifest: dict[str, Any] + skipped_server_only: list[str] + skipped_no_url: list[str] + skipped_unknown_type: list[str] + non_mod_files: list[Path] + + +def convert( + pack_root: Path, + *, + include_client_only: bool = True, + include_both: bool = True, +) -> ConvertResult: + pack = _load_pack(pack_root) + index = _load_index(pack_root) + + sync: list[dict[str, Any]] = [] + skipped_server_only: list[str] = [] + skipped_no_url: list[str] = [] + skipped_unknown_type: list[str] = [] + non_mod_files: list[Path] = [] + + for f in index.get("files", []): + rel_path = Path(f["file"]) + if not f.get("metafile"): + non_mod_files.append(rel_path) + continue + + meta = _load_pack_metafile(pack_root, f["file"]) + if meta.side == "server": + skipped_server_only.append(meta.name) + continue + if meta.side == "client" and not include_client_only: + continue + if meta.side == "both" and not include_both: + continue + + content_type = _content_type_for(meta.path) + if content_type is None: + skipped_unknown_type.append(str(meta.path)) + continue + if meta.url is None: + skipped_no_url.append(meta.name) + continue + + sync.append(_make_entry(meta, content_type)) + + manifest = { + "sync_version": 3, + "_generator": { + "tool": "packwiz-to-sms", + "pack_name": pack.get("name", ""), + "pack_version": pack.get("version", ""), + }, + "sync": sync, + } + + return ConvertResult( + manifest=manifest, + skipped_server_only=skipped_server_only, + skipped_no_url=skipped_no_url, + skipped_unknown_type=skipped_unknown_type, + non_mod_files=non_mod_files, + ) + + +def bundle_non_mods( + pack_root: Path, + non_mod_files: list[Path], + out_zip: Path, +) -> None: + """Zip non-mod files (config/, options.txt, etc.) into one archive + suitable for shipping as a simple-mod-sync `packed` entry with + `directory: "."`.""" + out_zip.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(out_zip, "w", zipfile.ZIP_DEFLATED) as zf: + for rel in non_mod_files: + abs_path = pack_root / rel + if not abs_path.is_file(): + continue + zf.write(abs_path, arcname=str(rel)) + + +def add_bundle_entry( + manifest: dict[str, Any], + bundle_url: str, + bundle_version: str, +) -> None: + manifest["sync"].append( + { + "url": bundle_url, + "name": "Pack overrides", + "version": bundle_version, + "type": "packed", + "directory": ".", + } + ) + + +# --- CLI ------------------------------------------------------------------- + + +def _build_arg_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="packwiz-to-sms", + description="Convert a packwiz pack to a simple-mod-sync manifest.", + ) + p.add_argument("pack_root", type=Path, help="Path to packwiz pack root (contains pack.toml)") + p.add_argument( + "-o", "--output", type=Path, default=None, + help="Write manifest to this file (default stdout).", + ) + p.add_argument( + "--bundle-non-mods", type=Path, default=None, metavar="ZIP_PATH", + help="Zip all non-mod files into this zip and add a 'packed' entry pointing at --bundle-url.", + ) + p.add_argument( + "--bundle-url", type=str, default=None, + help="URL where the non-mod bundle will be hosted. Required with --bundle-non-mods.", + ) + p.add_argument( + "--include-server-only", action="store_true", + help="Include mods marked side=server (off by default; only useful for mod-server distribution).", + ) + p.add_argument("--quiet", action="store_true", help="Suppress skip warnings on stderr.") + return p + + +def main(argv: list[str] | None = None) -> int: + args = _build_arg_parser().parse_args(argv) + + if not (args.pack_root / "pack.toml").is_file(): + print(f"error: {args.pack_root} does not contain pack.toml", file=sys.stderr) + return 2 + + result = convert(args.pack_root) + + if args.bundle_non_mods: + if not args.bundle_url: + print("error: --bundle-non-mods requires --bundle-url", file=sys.stderr) + return 2 + bundle_non_mods(args.pack_root, result.non_mod_files, args.bundle_non_mods) + pack = _load_pack(args.pack_root) + add_bundle_entry(result.manifest, args.bundle_url, pack.get("version", "1")) + + if not args.quiet: + for n in result.skipped_server_only: + print(f"skip (server-only): {n}", file=sys.stderr) + for n in result.skipped_no_url: + print(f"skip (no resolvable url, likely CurseForge metadata mode): {n}", file=sys.stderr) + for n in result.skipped_unknown_type: + print(f"skip (unknown content type for path): {n}", file=sys.stderr) + if result.non_mod_files and not args.bundle_non_mods: + print( + f"note: {len(result.non_mod_files)} non-mod file(s) in pack " + f"(config/, options.txt, ...) — pass --bundle-non-mods to ship them", + file=sys.stderr, + ) + + payload = json.dumps(result.manifest, indent=2) + "\n" + if args.output: + args.output.write_text(payload, encoding="utf-8") + else: + sys.stdout.write(payload) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/fixture-pack/config/voicechat-client.properties b/tests/fixture-pack/config/voicechat-client.properties new file mode 100644 index 0000000..1796e12 --- /dev/null +++ b/tests/fixture-pack/config/voicechat-client.properties @@ -0,0 +1,2 @@ +voice_chat_volume=0.8 +microphone_amplification=1.0 diff --git a/tests/fixture-pack/index.toml b/tests/fixture-pack/index.toml new file mode 100644 index 0000000..76a6825 --- /dev/null +++ b/tests/fixture-pack/index.toml @@ -0,0 +1,39 @@ +hash-format = "sha256" + +[[files]] +file = "mods/sodium.pw.toml" +hash = "0000000000000000000000000000000000000000000000000000000000000000" +metafile = true + +[[files]] +file = "mods/fabric-api.pw.toml" +hash = "0000000000000000000000000000000000000000000000000000000000000000" +metafile = true + +[[files]] +file = "mods/server-side-tool.pw.toml" +hash = "0000000000000000000000000000000000000000000000000000000000000000" +metafile = true + +[[files]] +file = "mods/cf-only-mod.pw.toml" +hash = "0000000000000000000000000000000000000000000000000000000000000000" +metafile = true + +[[files]] +file = "resourcepacks/branding.pw.toml" +hash = "0000000000000000000000000000000000000000000000000000000000000000" +metafile = true + +[[files]] +file = "shaderpacks/iris.pw.toml" +hash = "0000000000000000000000000000000000000000000000000000000000000000" +metafile = true + +[[files]] +file = "config/voicechat-client.properties" +hash = "1111111111111111111111111111111111111111111111111111111111111111" + +[[files]] +file = "options.txt" +hash = "2222222222222222222222222222222222222222222222222222222222222222" diff --git a/tests/fixture-pack/mods/cf-only-mod.pw.toml b/tests/fixture-pack/mods/cf-only-mod.pw.toml new file mode 100644 index 0000000..4cc4687 --- /dev/null +++ b/tests/fixture-pack/mods/cf-only-mod.pw.toml @@ -0,0 +1,13 @@ +name = "CF Only Mod" +filename = "cfmod-1.0.jar" +side = "both" + +[download] +hash-format = "murmur2" +hash = "1234567" +mode = "metadata:curseforge" + +[update] +[update.curseforge] +project-id = 12345 +file-id = 67890 diff --git a/tests/fixture-pack/mods/fabric-api.pw.toml b/tests/fixture-pack/mods/fabric-api.pw.toml new file mode 100644 index 0000000..1b19d01 --- /dev/null +++ b/tests/fixture-pack/mods/fabric-api.pw.toml @@ -0,0 +1,8 @@ +name = "Fabric API" +filename = "fabric-api-0.110.5+1.21.1.jar" +side = "both" + +[download] +url = "https://cdn.modrinth.com/data/P7dR8mSH/versions/4OZL6q6h/fabric-api-0.110.5%2B1.21.1.jar" +hash-format = "sha1" +hash = "1234560000000000000000000000000000000000" diff --git a/tests/fixture-pack/mods/server-side-tool.pw.toml b/tests/fixture-pack/mods/server-side-tool.pw.toml new file mode 100644 index 0000000..7c4e98b --- /dev/null +++ b/tests/fixture-pack/mods/server-side-tool.pw.toml @@ -0,0 +1,8 @@ +name = "Server Side Tool" +filename = "spark-1.10.55-fabric.jar" +side = "server" + +[download] +url = "https://example.com/spark.jar" +hash-format = "sha1" +hash = "9999990000000000000000000000000000000000" diff --git a/tests/fixture-pack/mods/sodium.pw.toml b/tests/fixture-pack/mods/sodium.pw.toml new file mode 100644 index 0000000..eee440c --- /dev/null +++ b/tests/fixture-pack/mods/sodium.pw.toml @@ -0,0 +1,13 @@ +name = "Sodium" +filename = "sodium-fabric-0.6.5+mc1.21.1.jar" +side = "client" + +[download] +url = "https://cdn.modrinth.com/data/AANobbMI/versions/EoNKHoLH/sodium-fabric-0.6.5%2Bmc1.21.1.jar" +hash-format = "sha1" +hash = "abcdef0000000000000000000000000000000000" + +[update] +[update.modrinth] +mod-id = "AANobbMI" +version = "EoNKHoLH" diff --git a/tests/fixture-pack/options.txt b/tests/fixture-pack/options.txt new file mode 100644 index 0000000..4a9619f --- /dev/null +++ b/tests/fixture-pack/options.txt @@ -0,0 +1,2 @@ +fov:90 +renderDistance:12 diff --git a/tests/fixture-pack/pack.toml b/tests/fixture-pack/pack.toml new file mode 100644 index 0000000..fc3d9a1 --- /dev/null +++ b/tests/fixture-pack/pack.toml @@ -0,0 +1,13 @@ +name = "Test Pack" +author = "tester" +version = "0.1.0" +pack-format = "packwiz:1.1.0" + +[index] +file = "index.toml" +hash-format = "sha256" +hash = "0000000000000000000000000000000000000000000000000000000000000000" + +[versions] +minecraft = "1.21.1" +fabric = "0.16.5" diff --git a/tests/fixture-pack/resourcepacks/branding.pw.toml b/tests/fixture-pack/resourcepacks/branding.pw.toml new file mode 100644 index 0000000..18ff1fa --- /dev/null +++ b/tests/fixture-pack/resourcepacks/branding.pw.toml @@ -0,0 +1,8 @@ +name = "Server Branding" +filename = "automc-branding-0.3.zip" +side = "client" + +[download] +url = "https://example.com/automc-branding-0.3.zip" +hash-format = "sha256" +hash = "aaaa000000000000000000000000000000000000000000000000000000000000" diff --git a/tests/fixture-pack/shaderpacks/iris.pw.toml b/tests/fixture-pack/shaderpacks/iris.pw.toml new file mode 100644 index 0000000..b70103a --- /dev/null +++ b/tests/fixture-pack/shaderpacks/iris.pw.toml @@ -0,0 +1,8 @@ +name = "Complementary Shaders" +filename = "complementary-r5.5.zip" +side = "client" + +[download] +url = "https://example.com/complementary-r5.5.zip" +hash-format = "sha256" +hash = "bbbb000000000000000000000000000000000000000000000000000000000000" diff --git a/tests/test_packwiz_to_sms.py b/tests/test_packwiz_to_sms.py new file mode 100644 index 0000000..a04899b --- /dev/null +++ b/tests/test_packwiz_to_sms.py @@ -0,0 +1,194 @@ +"""Unit tests for packwiz_to_sms.convert + CLI.""" + +from __future__ import annotations + +import json +import subprocess +import sys +import zipfile +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parent.parent +FIXTURE = Path(__file__).resolve().parent / "fixture-pack" +SCRIPT = ROOT / "packwiz_to_sms.py" + +sys.path.insert(0, str(ROOT)) +import packwiz_to_sms as p2s # noqa: E402 + + +def _by_name(sync: list[dict]) -> dict[str, dict]: + return {e["name"]: e for e in sync} + + +def test_convert_emits_client_and_both_only() -> None: + r = p2s.convert(FIXTURE) + names = {e["name"] for e in r.manifest["sync"]} + # client + both kept + assert "Sodium" in names + assert "Fabric API" in names + # server-only dropped + assert "Server Side Tool" not in names + # CurseForge metadata-mode dropped (no URL) + assert "CF Only Mod" not in names + # resourcepack + shader kept + assert "Server Branding" in names + assert "Complementary Shaders" in names + + +def test_convert_skip_reasons() -> None: + r = p2s.convert(FIXTURE) + assert "Server Side Tool" in r.skipped_server_only + assert "CF Only Mod" in r.skipped_no_url + assert r.skipped_unknown_type == [] + + +def test_convert_content_types() -> None: + r = p2s.convert(FIXTURE) + entries = _by_name(r.manifest["sync"]) + assert entries["Sodium"]["type"] == "mod" + assert entries["Fabric API"]["type"] == "mod" + assert entries["Server Branding"]["type"] == "resourcepack" + assert entries["Complementary Shaders"]["type"] == "shader" + + +def test_convert_url_preserved_verbatim() -> None: + r = p2s.convert(FIXTURE) + sodium = _by_name(r.manifest["sync"])["Sodium"] + assert sodium["url"] == ( + "https://cdn.modrinth.com/data/AANobbMI/versions/EoNKHoLH/" + "sodium-fabric-0.6.5%2Bmc1.21.1.jar" + ) + + +def test_convert_version_extracted_from_filename() -> None: + r = p2s.convert(FIXTURE) + entries = _by_name(r.manifest["sync"]) + assert entries["Sodium"]["version"] == "0.6.5+mc1.21.1" + assert entries["Fabric API"]["version"] == "0.110.5+1.21.1" + # Zip artifacts (resourcepacks, shaders) — same extractor handles them. + assert entries["Server Branding"]["version"] == "0.3" + assert entries["Complementary Shaders"]["version"] == "r5.5" + + +def test_convert_lists_non_mod_files() -> None: + r = p2s.convert(FIXTURE) + files = {str(p) for p in r.non_mod_files} + assert "config/voicechat-client.properties" in files + assert "options.txt" in files + + +def test_convert_top_level_shape() -> None: + r = p2s.convert(FIXTURE) + assert r.manifest["sync_version"] == 3 + assert isinstance(r.manifest["sync"], list) + assert r.manifest["_generator"]["tool"] == "packwiz-to-sms" + assert r.manifest["_generator"]["pack_name"] == "Test Pack" + assert r.manifest["_generator"]["pack_version"] == "0.1.0" + + +def test_bundle_non_mods(tmp_path: Path) -> None: + r = p2s.convert(FIXTURE) + out = tmp_path / "overrides.zip" + p2s.bundle_non_mods(FIXTURE, r.non_mod_files, out) + with zipfile.ZipFile(out) as zf: + names = set(zf.namelist()) + assert "config/voicechat-client.properties" in names + assert "options.txt" in names + + +def test_bundle_entry_appended() -> None: + r = p2s.convert(FIXTURE) + n_before = len(r.manifest["sync"]) + p2s.add_bundle_entry(r.manifest, "https://example.com/overrides.zip", "0.1.0") + assert len(r.manifest["sync"]) == n_before + 1 + bundle = r.manifest["sync"][-1] + assert bundle["type"] == "packed" + assert bundle["directory"] == "." + assert bundle["url"] == "https://example.com/overrides.zip" + + +def test_cli_writes_to_stdout(tmp_path: Path) -> None: + proc = subprocess.run( + [sys.executable, str(SCRIPT), str(FIXTURE), "--quiet"], + capture_output=True, text=True, check=True, + ) + payload = json.loads(proc.stdout) + assert payload["sync_version"] == 3 + assert any(e["name"] == "Sodium" for e in payload["sync"]) + + +def test_cli_writes_to_output_flag(tmp_path: Path) -> None: + out = tmp_path / "manifest.json" + subprocess.run( + [sys.executable, str(SCRIPT), str(FIXTURE), "--quiet", "-o", str(out)], + check=True, + ) + payload = json.loads(out.read_text()) + assert payload["sync_version"] == 3 + + +def test_cli_bundle_requires_url(tmp_path: Path) -> None: + proc = subprocess.run( + [sys.executable, str(SCRIPT), str(FIXTURE), + "--bundle-non-mods", str(tmp_path / "x.zip"), "--quiet"], + capture_output=True, text=True, + ) + assert proc.returncode == 2 + assert "--bundle-url" in proc.stderr + + +def test_cli_full_bundle_pipeline(tmp_path: Path) -> None: + bundle = tmp_path / "overrides.zip" + out = tmp_path / "manifest.json" + subprocess.run( + [sys.executable, str(SCRIPT), str(FIXTURE), "--quiet", + "-o", str(out), + "--bundle-non-mods", str(bundle), + "--bundle-url", "https://example.com/overrides.zip"], + check=True, + ) + payload = json.loads(out.read_text()) + bundle_entry = next( + (e for e in payload["sync"] if e.get("type") == "packed"), None + ) + assert bundle_entry is not None + assert bundle_entry["url"] == "https://example.com/overrides.zip" + assert bundle.is_file() + + +def test_cli_missing_pack_toml(tmp_path: Path) -> None: + proc = subprocess.run( + [sys.executable, str(SCRIPT), str(tmp_path), "--quiet"], + capture_output=True, text=True, + ) + assert proc.returncode == 2 + assert "pack.toml" in proc.stderr + + +def test_convert_against_real_packwiz_example_pack(tmp_path: Path) -> None: + """Integration: clone the upstream packwiz-example-pack and convert it. + + Skipped if no network / git unavailable. Verifies the tool works on + a real pack, not just our hand-rolled fixture. + """ + pack_root = tmp_path / "example-pack" + try: + subprocess.run( + ["git", "clone", "--depth", "1", "--branch", "v1", + "https://github.com/packwiz/packwiz-example-pack.git", + str(pack_root)], + check=True, capture_output=True, timeout=30, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): + pytest.skip("network/git unavailable") + + r = p2s.convert(pack_root) + assert r.manifest["sync_version"] == 3 + # Example pack has at least two mods we recognize + names = {e["name"] for e in r.manifest["sync"]} + assert any("Borderless" in n for n in names) + # All entries have a URL + assert all(e.get("url") for e in r.manifest["sync"])