Files
claude-timemachine 438b1f7c65 diff-based mod removal via --previous-manifest
Adds removal-detection: when --previous-manifest <path> is given,
the converter diffs the previous publish against current packwiz state
and emits modify[].type=remove entries for mods/resourcepacks/etc that
disappeared, using simple-mod-sync's on-disk naming convention as the
regex pattern.

Reverse-engineered from upstream source:
- simple-mod-sync writes <sanitized_name>-<sanitized_version>.<ext>
- StringUtils.sanitize strips [^a-zA-Z0-9.\-_]
- GetOlderVersion() finds files starting with <name>- and auto-deletes
  on version bumps. So version upgrades need no converter handling;
  only full removals do.

8 new tests including end-to-end CLI verification with a synthetic
previous manifest. 23/23 pass.
2026-06-02 11:50:51 +02:00

388 lines
13 KiB
Python

#!/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 <base-url>/<bundle-name> 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",
}
# simple-mod-sync writes downloaded files as <sanitized_name>-<sanitized_version>.<ext>
# under <type>'s target directory. See StringUtils.sanitize() in the mod source:
# replaceAll("[^a-zA-Z0-9.\\-_]", "")
_SANITIZE_RE = re.compile(r"[^a-zA-Z0-9.\-_]")
_TYPE_TO_DIR_AND_EXT = {
"mod": ("mods", "jar"),
"resourcepack": ("resourcepacks", "zip"),
"shader": ("shaderpacks", "zip"),
"datapack": ("datapacks", "zip"),
}
def _sanitize(s: str) -> str:
return _SANITIZE_RE.sub("", s)
def _remove_pattern_for(entry: dict[str, Any]) -> str | None:
"""Build a regex that matches any on-disk file simple-mod-sync would have
written for this content entry (current or older versions).
Mirrors simple-mod-sync's `GetProjectName(): name + "-"` prefix search
in `DirUtils.DirContains` (which uses startswith)."""
ctype = entry.get("type")
spec = _TYPE_TO_DIR_AND_EXT.get(ctype)
if spec is None:
return None
target_dir, ext = spec
safe_name = _sanitize(entry.get("name", ""))
if not safe_name:
return None
# Anchor whole path, match any version suffix + the extension.
return rf"^{re.escape(target_dir)}/{re.escape(safe_name)}-.*\.{ext}$"
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]
removed_entries: list[dict[str, Any]] = None # populated when --previous-manifest given
def __post_init__(self) -> None:
if self.removed_entries is None:
self.removed_entries = []
def diff_removals(
previous_sync: list[dict[str, Any]],
current_sync: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Return simple-mod-sync `modify` entries that delete files for content
present in `previous_sync` but absent from `current_sync`.
Identity is `(name, type)` — covers the common case (rename a mod →
treated as remove+add, which is the right behavior because the old
file's prefix no longer matches anything in the sync list).
"""
cur_keys = {(e.get("name"), e.get("type")) for e in current_sync}
removals: list[dict[str, Any]] = []
for prev in previous_sync:
key = (prev.get("name"), prev.get("type"))
if key in cur_keys:
continue
pattern = _remove_pattern_for(prev)
if pattern is None:
continue
removals.append({
"type": "remove",
"pattern": pattern,
"path": ".",
})
return removals
def convert(
pack_root: Path,
*,
include_client_only: bool = True,
include_both: bool = True,
previous_manifest: dict[str, Any] | None = None,
) -> 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: dict[str, Any] = {
"sync_version": 3,
"_generator": {
"tool": "packwiz-to-sms",
"pack_name": pack.get("name", ""),
"pack_version": pack.get("version", ""),
},
"sync": sync,
}
removed_entries: list[dict[str, Any]] = []
if previous_manifest is not None:
prev_sync = previous_manifest.get("sync", [])
modify = diff_removals(prev_sync, sync)
if modify:
manifest["modify"] = modify
removed_entries = modify
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,
removed_entries=removed_entries,
)
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(
"--previous-manifest", type=Path, default=None, metavar="JSON_PATH",
help="Path to the previously-published manifest. Names that disappeared "
"between previous and current are emitted as modify[].type=remove entries "
"matching simple-mod-sync's on-disk naming, so clients auto-clean removed mods.",
)
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
previous_manifest = None
if args.previous_manifest:
if not args.previous_manifest.is_file():
print(
f"warning: --previous-manifest {args.previous_manifest} does not exist, "
f"skipping removal detection",
file=sys.stderr,
)
else:
previous_manifest = json.loads(args.previous_manifest.read_text(encoding="utf-8"))
result = convert(args.pack_root, previous_manifest=previous_manifest)
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,
)
for r in result.removed_entries:
print(f"remove: {r['pattern']}", 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())