From 70d20f0f182fddd73a03b88ac2a3565e5ead6087 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 22:30:45 +0200 Subject: [PATCH 1/7] cli: add m2pack binary discovery helper Resolve the m2pack-secure binary via M2PACK_BINARY env var or PATH, raising ValidationError(m2pack_not_found) when neither works. --- src/metin_release/m2pack_binary.py | 51 ++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/metin_release/m2pack_binary.py diff --git a/src/metin_release/m2pack_binary.py b/src/metin_release/m2pack_binary.py new file mode 100644 index 0000000..1ce41c9 --- /dev/null +++ b/src/metin_release/m2pack_binary.py @@ -0,0 +1,51 @@ +"""Resolve the m2pack binary for the m2pack wrapper subcommands. + +Resolution order: + +1. ``M2PACK_BINARY`` environment variable, if set, non-empty, and points at + an existing file. +2. :func:`shutil.which` on ``m2pack``. + +If neither works we raise :class:`~metin_release.errors.ValidationError` so +the CLI exits 1 with a clear, actionable message. Import of this module +must never trigger filesystem access — all discovery is runtime. +""" + +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +from .errors import ValidationError + + +ENV_VAR = "M2PACK_BINARY" + +_NOT_FOUND_HINT = ( + "m2pack binary not found. Set the M2PACK_BINARY environment variable to " + "an absolute path, or make `m2pack` available on PATH. Build it from " + "https://gitea.jakubkadlec.dev/metin-server/m2pack-secure (see its " + "CMakeLists.txt)." +) + + +def resolve_m2pack_binary(env: dict[str, str] | None = None) -> Path: + """Return the resolved path to the m2pack binary or raise ValidationError.""" + environ = env if env is not None else os.environ + override = (environ.get(ENV_VAR) or "").strip() + if override: + candidate = Path(override).expanduser() + if candidate.is_file(): + return candidate.resolve() + raise ValidationError( + f"{ENV_VAR}={override!r} does not point at an existing file. " + f"{_NOT_FOUND_HINT}", + error_code="m2pack_not_found", + ) + + which = shutil.which("m2pack") + if which: + return Path(which).resolve() + + raise ValidationError(_NOT_FOUND_HINT, error_code="m2pack_not_found") From ae0cbb7e9bcc2dbf028e19d9892c9f2acc7abe49 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 22:30:51 +0200 Subject: [PATCH 2/7] cli: implement m2pack build and verify wrappers Shell out to m2pack-secure with --json, parse its envelope, and translate into the standard metin-release Result envelope under data.m2pack. Non-zero exit and non-JSON output map to SubprocessError with m2pack_failed / m2pack_invalid_json / m2pack_empty_output codes. --- src/metin_release/commands/_m2pack_runner.py | 78 ++++++++++++++++++++ src/metin_release/commands/m2pack_build.py | 47 ++++++++++++ src/metin_release/commands/m2pack_verify.py | 45 +++++++++++ 3 files changed, 170 insertions(+) create mode 100644 src/metin_release/commands/_m2pack_runner.py create mode 100644 src/metin_release/commands/m2pack_build.py create mode 100644 src/metin_release/commands/m2pack_verify.py diff --git a/src/metin_release/commands/_m2pack_runner.py b/src/metin_release/commands/_m2pack_runner.py new file mode 100644 index 0000000..018d63a --- /dev/null +++ b/src/metin_release/commands/_m2pack_runner.py @@ -0,0 +1,78 @@ +"""Shared helper to invoke the real m2pack CLI and translate its JSON. + +m2pack-secure emits its own JSON envelopes with ``--json``. Their shape is +not identical to our :class:`~metin_release.result.Result` envelope, so we +wrap the raw dict under ``data["m2pack"]`` and promote a few well-known +fields (artifact paths, counts) where it makes sense per command. + +If m2pack exits non-zero or prints non-JSON, we raise +:class:`~metin_release.errors.SubprocessError` so the CLI exits with code +1 and a readable error envelope. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from typing import Any + +from ..errors import SubprocessError +from ..log import get_logger +from ..m2pack_binary import resolve_m2pack_binary + + +def run_m2pack(subcommand: str, argv: list[str]) -> dict[str, Any]: + """Invoke ``m2pack [argv...] --json`` and return parsed JSON. + + Raises :class:`SubprocessError` (exit code 1) on any failure: binary + missing, non-zero exit, empty stdout, or non-JSON stdout. Missing + binary is handled inside :func:`resolve_m2pack_binary` by raising a + :class:`~metin_release.errors.ValidationError` which the dispatcher + already converts into the standard error envelope. + """ + log = get_logger() + binary: Path = resolve_m2pack_binary() + cmd = [str(binary), subcommand, *argv, "--json"] + log.debug("running m2pack: %s", " ".join(cmd)) + try: + proc = subprocess.run( + cmd, capture_output=True, text=True, check=False + ) + except OSError as exc: + raise SubprocessError( + f"failed to spawn m2pack: {exc}", + error_code="m2pack_spawn_failed", + ) from exc + + stdout = (proc.stdout or "").strip() + stderr = (proc.stderr or "").strip() + + if proc.returncode != 0: + detail = stderr or stdout or f"exit code {proc.returncode}" + raise SubprocessError( + f"m2pack {subcommand} failed (rc={proc.returncode}): {detail}", + error_code="m2pack_failed", + ) + + if not stdout: + raise SubprocessError( + f"m2pack {subcommand} produced no stdout; stderr={stderr!r}", + error_code="m2pack_empty_output", + ) + + try: + parsed = json.loads(stdout) + except json.JSONDecodeError as exc: + raise SubprocessError( + f"m2pack {subcommand} returned non-JSON output: {exc}; " + f"first 200 chars={stdout[:200]!r}", + error_code="m2pack_invalid_json", + ) from exc + + if not isinstance(parsed, dict): + raise SubprocessError( + f"m2pack {subcommand} JSON was not an object: {type(parsed).__name__}", + error_code="m2pack_invalid_json", + ) + return parsed diff --git a/src/metin_release/commands/m2pack_build.py b/src/metin_release/commands/m2pack_build.py new file mode 100644 index 0000000..9eb28d5 --- /dev/null +++ b/src/metin_release/commands/m2pack_build.py @@ -0,0 +1,47 @@ +"""m2pack build: wrap ``m2pack build`` to produce a signed .m2p archive.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from ..result import Result +from ._m2pack_runner import run_m2pack + + +def add_parser(sub: argparse._SubParsersAction) -> argparse.ArgumentParser: + p = sub.add_parser("build", help="Build a signed .m2p archive from a source directory.") + p.add_argument("--input", required=True, type=Path, help="Client asset source directory.") + p.add_argument("--output", required=True, type=Path, help="Output .m2p archive path.") + p.add_argument("--key", required=True, type=Path, help="Master content key file.") + p.add_argument( + "--sign-secret-key", + required=True, + type=Path, + help="Ed25519 signing secret key file.", + ) + p.add_argument("--key-id", type=int, help="Content key id (default 1).") + return p + + +def run(ctx, args: argparse.Namespace) -> Result: + argv = [ + "--input", str(args.input), + "--output", str(args.output), + "--key", str(args.key), + "--sign-secret-key", str(args.sign_secret_key), + ] + if args.key_id is not None: + argv.extend(["--key-id", str(args.key_id)]) + + raw = run_m2pack("build", argv) + status = "built" if raw.get("ok", True) else "failed" + return Result( + command="m2pack build", + ok=bool(raw.get("ok", True)), + status=status, + data={ + "artifacts": {"archive_path": str(args.output)}, + "m2pack": raw, + }, + ) diff --git a/src/metin_release/commands/m2pack_verify.py b/src/metin_release/commands/m2pack_verify.py new file mode 100644 index 0000000..c34d11c --- /dev/null +++ b/src/metin_release/commands/m2pack_verify.py @@ -0,0 +1,45 @@ +"""m2pack verify: wrap ``m2pack verify`` to validate manifest + optional decrypt.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from ..result import Result +from ._m2pack_runner import run_m2pack + + +def add_parser(sub: argparse._SubParsersAction) -> argparse.ArgumentParser: + p = sub.add_parser("verify", help="Verify an .m2p archive (manifest + signature).") + p.add_argument("--archive", required=True, type=Path, help="Path to .m2p archive.") + p.add_argument( + "--public-key", + type=Path, + help="Ed25519 public key file (for signature verification).", + ) + p.add_argument( + "--key", + type=Path, + help="Master content key file (enables full-decrypt verification).", + ) + return p + + +def run(ctx, args: argparse.Namespace) -> Result: + argv = ["--archive", str(args.archive)] + if args.public_key is not None: + argv.extend(["--public-key", str(args.public_key)]) + if args.key is not None: + argv.extend(["--key", str(args.key)]) + + raw = run_m2pack("verify", argv) + ok = bool(raw.get("ok", True)) + return Result( + command="m2pack verify", + ok=ok, + status="verified" if ok else "failed", + data={ + "artifacts": {"archive_path": str(args.archive)}, + "m2pack": raw, + }, + ) From 197c5ba8a29cc51089d0f80d0f56e065d55541fc Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 22:30:56 +0200 Subject: [PATCH 3/7] cli: implement m2pack diff and export-runtime-key wrappers diff promotes m2pack's added/removed/changed/unchanged counts into data.stats when m2pack reports them as lists or ints. export-runtime-key follows the real binary's --key/--public-key/--output/--key-id/--format surface (not the original plan's --pack/--master-key). --- src/metin_release/commands/m2pack_diff.py | 45 +++++++++++++++ .../commands/m2pack_export_runtime_key.py | 57 +++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 src/metin_release/commands/m2pack_diff.py create mode 100644 src/metin_release/commands/m2pack_export_runtime_key.py diff --git a/src/metin_release/commands/m2pack_diff.py b/src/metin_release/commands/m2pack_diff.py new file mode 100644 index 0000000..4eebf11 --- /dev/null +++ b/src/metin_release/commands/m2pack_diff.py @@ -0,0 +1,45 @@ +"""m2pack diff: wrap ``m2pack diff`` to compare directories and/or .m2p archives.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from ..result import Result +from ._m2pack_runner import run_m2pack + + +def add_parser(sub: argparse._SubParsersAction) -> argparse.ArgumentParser: + p = sub.add_parser( + "diff", + help="Diff two directories and/or .m2p archives (left vs right).", + ) + p.add_argument("--left", required=True, type=Path, help="Left side: directory or .m2p archive.") + p.add_argument("--right", required=True, type=Path, help="Right side: directory or .m2p archive.") + return p + + +def run(ctx, args: argparse.Namespace) -> Result: + argv = ["--left", str(args.left), "--right", str(args.right)] + raw = run_m2pack("diff", argv) + + # Best-effort promotion of the diff counts m2pack reports. Fall back + # gracefully when the key is missing or the shape differs. + stats: dict[str, object] = {} + for key in ("added", "removed", "changed", "unchanged"): + value = raw.get(key) + if isinstance(value, int): + stats[f"{key}_count"] = value + elif isinstance(value, list): + stats[f"{key}_count"] = len(value) + + ok = bool(raw.get("ok", True)) + return Result( + command="m2pack diff", + ok=ok, + status="diffed" if ok else "failed", + data={ + "stats": stats, + "m2pack": raw, + }, + ) diff --git a/src/metin_release/commands/m2pack_export_runtime_key.py b/src/metin_release/commands/m2pack_export_runtime_key.py new file mode 100644 index 0000000..78773e0 --- /dev/null +++ b/src/metin_release/commands/m2pack_export_runtime_key.py @@ -0,0 +1,57 @@ +"""m2pack export-runtime-key: wrap ``m2pack export-runtime-key``. + +Emits a launcher runtime-key payload in either ``json`` or ``blob`` form +from the master content key + signing public key. The plan originally +described this as ``--pack --master-key --out``; the real m2pack CLI +uses ``--key --public-key --key-id --format --output``, so we follow the +real tool. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from ..result import Result +from ._m2pack_runner import run_m2pack + + +def add_parser(sub: argparse._SubParsersAction) -> argparse.ArgumentParser: + p = sub.add_parser( + "export-runtime-key", + help="Export a launcher runtime-key payload (json or blob form).", + ) + p.add_argument("--key", required=True, type=Path, help="Master content key file.") + p.add_argument("--public-key", required=True, type=Path, help="Ed25519 public key file.") + p.add_argument("--output", required=True, type=Path, help="Output payload path.") + p.add_argument("--key-id", type=int, help="Content key id (default 1).") + p.add_argument( + "--format", + choices=("json", "blob"), + help="Payload format (default json).", + ) + return p + + +def run(ctx, args: argparse.Namespace) -> Result: + argv = [ + "--key", str(args.key), + "--public-key", str(args.public_key), + "--output", str(args.output), + ] + if args.key_id is not None: + argv.extend(["--key-id", str(args.key_id)]) + if args.format is not None: + argv.extend(["--format", args.format]) + + raw = run_m2pack("export-runtime-key", argv) + ok = bool(raw.get("ok", True)) + return Result( + command="m2pack export-runtime-key", + ok=ok, + status="exported" if ok else "failed", + data={ + "artifacts": {"runtime_key_path": str(args.output)}, + "m2pack": raw, + }, + ) From a289cd7c25415f56ad835d0cd0ab24ef491a624e Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 22:31:00 +0200 Subject: [PATCH 4/7] cli: add m2pack command group to dispatcher Register the m2pack subparser and wire build/verify/diff/export-runtime-key into _COMMAND_MAP alongside the existing release group. --- src/metin_release/cli.py | 23 +++++++++++++++++++++++ src/metin_release/commands/__init__.py | 8 ++++++++ 2 files changed, 31 insertions(+) diff --git a/src/metin_release/cli.py b/src/metin_release/cli.py index 9b85ae5..6a7320b 100644 --- a/src/metin_release/cli.py +++ b/src/metin_release/cli.py @@ -11,6 +11,10 @@ from .commands import ( build_manifest, diff_remote, inspect, + m2pack_build, + m2pack_diff, + m2pack_export_runtime_key, + m2pack_verify, promote, publish, sign, @@ -68,6 +72,18 @@ def _build_parser() -> argparse.ArgumentParser: sp = mod.add_parser(rsub) _add_common_flags(sp) + m2pack = sub.add_parser("m2pack", help="m2pack-secure archive commands.") + msub = m2pack.add_subparsers(dest="cmd", metavar="") + msub.required = True + for mod in ( + m2pack_build, + m2pack_verify, + m2pack_diff, + m2pack_export_runtime_key, + ): + sp = mod.add_parser(msub) + _add_common_flags(sp) + return parser @@ -80,6 +96,13 @@ _COMMAND_MAP: dict[tuple[str, str], tuple[str, CommandFn]] = { ("release", "promote"): ("release promote", promote.run), ("release", "verify-public"): ("release verify-public", verify_public.run), ("release", "publish"): ("release publish", publish.run), + ("m2pack", "build"): ("m2pack build", m2pack_build.run), + ("m2pack", "verify"): ("m2pack verify", m2pack_verify.run), + ("m2pack", "diff"): ("m2pack diff", m2pack_diff.run), + ("m2pack", "export-runtime-key"): ( + "m2pack export-runtime-key", + m2pack_export_runtime_key.run, + ), } diff --git a/src/metin_release/commands/__init__.py b/src/metin_release/commands/__init__.py index 8cde4e5..aa9fe5f 100644 --- a/src/metin_release/commands/__init__.py +++ b/src/metin_release/commands/__init__.py @@ -4,6 +4,10 @@ from . import ( build_manifest, diff_remote, inspect, + m2pack_build, + m2pack_diff, + m2pack_export_runtime_key, + m2pack_verify, promote, publish, sign, @@ -15,6 +19,10 @@ __all__ = [ "build_manifest", "diff_remote", "inspect", + "m2pack_build", + "m2pack_diff", + "m2pack_export_runtime_key", + "m2pack_verify", "promote", "publish", "sign", From c4c65e2fe76d24ec4f7825d6f7406f3d4f7be2bd Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 22:31:05 +0200 Subject: [PATCH 5/7] mcp: expose m2pack_* tools in the mcp server Add four ToolSpec entries (m2pack_build, m2pack_verify, m2pack_diff, m2pack_export_runtime_key) mirroring the CLI argparse signatures. server.py auto-enumerates TOOL_SPECS so no wiring change needed. --- src/metin_release_mcp/tool_defs.py | 49 ++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/metin_release_mcp/tool_defs.py b/src/metin_release_mcp/tool_defs.py index aab9063..dbf88b4 100644 --- a/src/metin_release_mcp/tool_defs.py +++ b/src/metin_release_mcp/tool_defs.py @@ -64,6 +64,7 @@ class ToolSpec: # --------------------------------------------------------------------------- _RELEASE = ("release",) +_M2PACK = ("m2pack",) TOOL_SPECS: tuple[ToolSpec, ...] = ( @@ -164,6 +165,54 @@ TOOL_SPECS: tuple[ToolSpec, ...] = ( FieldSpec("dry_run_upload", "boolean", description="rsync --dry-run for upload + promote."), ), ), + ToolSpec( + name="m2pack_build", + subcommand=_M2PACK + ("build",), + description="Build a signed .m2p archive from a client asset directory via m2pack-secure.", + fields=( + FieldSpec("input", "path", required=True, description="Client asset source directory."), + FieldSpec("output", "path", required=True, description="Output .m2p archive path."), + FieldSpec("key", "path", required=True, description="Master content key file."), + FieldSpec( + "sign_secret_key", + "path", + required=True, + description="Ed25519 signing secret key file.", + ), + FieldSpec("key_id", "integer", description="Content key id (default 1)."), + ), + ), + ToolSpec( + name="m2pack_verify", + subcommand=_M2PACK + ("verify",), + description="Verify an .m2p archive's manifest signature and optionally full decrypt.", + fields=( + FieldSpec("archive", "path", required=True, description="Path to .m2p archive."), + FieldSpec("public_key", "path", description="Ed25519 public key file."), + FieldSpec("key", "path", description="Master content key file for full-decrypt verify."), + ), + ), + ToolSpec( + name="m2pack_diff", + subcommand=_M2PACK + ("diff",), + description="Diff two directories and/or .m2p archives (left vs right).", + fields=( + FieldSpec("left", "path", required=True, description="Left side: directory or .m2p archive."), + FieldSpec("right", "path", required=True, description="Right side: directory or .m2p archive."), + ), + ), + ToolSpec( + name="m2pack_export_runtime_key", + subcommand=_M2PACK + ("export-runtime-key",), + description="Export a launcher runtime-key payload (json or blob form) from master+public keys.", + fields=( + FieldSpec("key", "path", required=True, description="Master content key file."), + FieldSpec("public_key", "path", required=True, description="Ed25519 public key file."), + FieldSpec("output", "path", required=True, description="Output payload path."), + FieldSpec("key_id", "integer", description="Content key id (default 1)."), + FieldSpec("format", "string", description="Payload format: json or blob (default json)."), + ), + ), ) From 1201ec50d29555666926fe3b473b6049c36f5f0f Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 22:31:11 +0200 Subject: [PATCH 6/7] tests: cover m2pack cli and mcp additions Add discovery-helper tests (env var override, PATH fallback, missing binary) and command tests that point M2PACK_BINARY at a Python stub script echoing canned JSON per subcommand. Cover success paths, non-zero exit, non-JSON output, JSON-array output, and missing binary. Extend the MCP schema mirror test to cover all 12 tools and add dispatch tests for the new m2pack_* argv translation. --- tests/mcp/test_tool_dispatch.py | 74 ++++++++ tests/mcp/test_tool_schema.py | 22 ++- tests/test_m2pack_binary.py | 68 +++++++ tests/test_m2pack_commands.py | 307 ++++++++++++++++++++++++++++++++ 4 files changed, 466 insertions(+), 5 deletions(-) create mode 100644 tests/test_m2pack_binary.py create mode 100644 tests/test_m2pack_commands.py diff --git a/tests/mcp/test_tool_dispatch.py b/tests/mcp/test_tool_dispatch.py index b2a3d2d..6bd9dd7 100644 --- a/tests/mcp/test_tool_dispatch.py +++ b/tests/mcp/test_tool_dispatch.py @@ -199,6 +199,80 @@ def test_dispatch_invalid_input_returns_wrapper_error(): assert envelope["error"]["code"] == "invalid_tool_input" +def test_m2pack_build_translates_all_flags(): + spec = TOOLS_BY_NAME["m2pack_build"] + argv = build_cli_args( + spec, + { + "input": "/src", + "output": "/out/a.m2p", + "key": "/ck", + "sign_secret_key": "/sk", + "key_id": 2, + }, + ) + assert argv[0:3] == ["m2pack", "build", "--json"] + assert "--input" in argv and argv[argv.index("--input") + 1] == "/src" + assert "--output" in argv and argv[argv.index("--output") + 1] == "/out/a.m2p" + assert "--sign-secret-key" in argv + assert argv[argv.index("--sign-secret-key") + 1] == "/sk" + assert "--key-id" in argv and argv[argv.index("--key-id") + 1] == "2" + + +def test_m2pack_verify_omits_optional_keys(): + spec = TOOLS_BY_NAME["m2pack_verify"] + argv = build_cli_args(spec, {"archive": "/a.m2p"}) + assert argv == ["m2pack", "verify", "--json", "--archive", "/a.m2p"] + + +def test_m2pack_diff_requires_both_sides(): + from metin_release_mcp.errors import InvalidToolInputError + + spec = TOOLS_BY_NAME["m2pack_diff"] + with pytest.raises(InvalidToolInputError): + build_cli_args(spec, {"left": "/L"}) + argv = build_cli_args(spec, {"left": "/L", "right": "/R"}) + assert argv == ["m2pack", "diff", "--json", "--left", "/L", "--right", "/R"] + + +def test_m2pack_export_runtime_key_with_format(): + spec = TOOLS_BY_NAME["m2pack_export_runtime_key"] + argv = build_cli_args( + spec, + { + "key": "/ck", + "public_key": "/pk", + "output": "/out", + "format": "blob", + "key_id": 4, + }, + ) + assert argv[0:3] == ["m2pack", "export-runtime-key", "--json"] + assert "--format" in argv and argv[argv.index("--format") + 1] == "blob" + assert "--key-id" in argv and argv[argv.index("--key-id") + 1] == "4" + + +def test_dispatch_m2pack_build_through_fake_runner(stub_runner): + runner = stub_runner( + {"ok": True, "command": "m2pack build", "status": "built", "artifacts": {"archive_path": "/x.m2p"}} + ) + envelope, _ = server.dispatch( + "m2pack_build", + { + "input": "/src", + "output": "/x.m2p", + "key": "/ck", + "sign_secret_key": "/sk", + }, + ) + assert envelope["ok"] is True + call = runner.calls[0] + assert "m2pack" in call and "build" in call + assert "--json" in call + assert "--input" in call and "/src" in call + assert "--sign-secret-key" in call and "/sk" in call + + def test_dispatch_unparseable_output_returns_wrapper_error(monkeypatch): def boom(argv_tail): from metin_release_mcp.errors import CliUnparseableOutputError diff --git a/tests/mcp/test_tool_schema.py b/tests/mcp/test_tool_schema.py index aa9ad8e..a5af77f 100644 --- a/tests/mcp/test_tool_schema.py +++ b/tests/mcp/test_tool_schema.py @@ -10,6 +10,10 @@ from metin_release.commands import ( build_manifest, diff_remote, inspect, + m2pack_build, + m2pack_diff, + m2pack_export_runtime_key, + m2pack_verify, promote, publish, sign, @@ -35,17 +39,21 @@ EXPECTED_TOOL_NAMES = { "release_promote", "release_verify_public", "release_publish", + "m2pack_build", + "m2pack_verify", + "m2pack_diff", + "m2pack_export_runtime_key", } -def test_tool_catalogue_is_exactly_the_phase_one_set(): +def test_tool_catalogue_is_exactly_the_phase_one_plus_four_set(): assert {t.name for t in TOOL_SPECS} == EXPECTED_TOOL_NAMES - assert len(TOOL_SPECS) == 8 + assert len(TOOL_SPECS) == 12 -def test_every_tool_has_release_subcommand_and_description(): +def test_every_tool_has_known_group_and_description(): for spec in TOOL_SPECS: - assert spec.subcommand[0] == "release" + assert spec.subcommand[0] in {"release", "m2pack"} assert spec.description.strip() schema = json_schema(spec) assert schema["type"] == "object" @@ -82,6 +90,10 @@ _COMMAND_MODULES = { "release_promote": promote, "release_verify_public": verify_public, "release_publish": publish, + "m2pack_build": m2pack_build, + "m2pack_verify": m2pack_verify, + "m2pack_diff": m2pack_diff, + "m2pack_export_runtime_key": m2pack_export_runtime_key, } @@ -121,7 +133,7 @@ def test_unknown_tool_rejected_by_dispatch(): def test_unknown_tool_name_not_in_catalogue(): assert "release_rollback" not in TOOLS_BY_NAME assert "erp_reserve" not in TOOLS_BY_NAME - assert "m2pack_build" not in TOOLS_BY_NAME + assert "launcher_publish" not in TOOLS_BY_NAME def test_build_cli_args_rejects_missing_required(): diff --git a/tests/test_m2pack_binary.py b/tests/test_m2pack_binary.py new file mode 100644 index 0000000..0964662 --- /dev/null +++ b/tests/test_m2pack_binary.py @@ -0,0 +1,68 @@ +"""Tests for the m2pack binary discovery helper.""" + +from __future__ import annotations + +import os +import stat +from pathlib import Path + +import pytest + +from metin_release.errors import ValidationError +from metin_release.m2pack_binary import ENV_VAR, resolve_m2pack_binary + + +def _make_stub(tmp_path: Path, name: str = "m2pack") -> Path: + stub = tmp_path / name + stub.write_text("#!/bin/sh\necho '{}'\n") + stub.chmod(stub.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + return stub + + +def test_env_var_override_wins(tmp_path: Path, monkeypatch): + stub = _make_stub(tmp_path) + # Scrub PATH so shutil.which can't resolve m2pack + monkeypatch.setenv("PATH", "/nonexistent") + monkeypatch.setenv(ENV_VAR, str(stub)) + resolved = resolve_m2pack_binary() + assert resolved == stub.resolve() + + +def test_env_var_empty_falls_through_to_path(tmp_path: Path, monkeypatch): + stub = _make_stub(tmp_path) + monkeypatch.setenv(ENV_VAR, " ") # blank-ish + monkeypatch.setenv("PATH", str(tmp_path)) + resolved = resolve_m2pack_binary() + assert resolved == stub.resolve() + + +def test_env_var_pointing_nowhere_raises(tmp_path: Path, monkeypatch): + monkeypatch.setenv(ENV_VAR, str(tmp_path / "does-not-exist")) + monkeypatch.setenv("PATH", "/nonexistent") + with pytest.raises(ValidationError) as exc: + resolve_m2pack_binary() + assert exc.value.error_code == "m2pack_not_found" + + +def test_path_fallback_used_when_env_unset(tmp_path: Path, monkeypatch): + stub = _make_stub(tmp_path) + monkeypatch.delenv(ENV_VAR, raising=False) + monkeypatch.setenv("PATH", str(tmp_path)) + resolved = resolve_m2pack_binary() + assert resolved == stub.resolve() + + +def test_missing_binary_raises_validation_error(monkeypatch): + monkeypatch.delenv(ENV_VAR, raising=False) + monkeypatch.setenv("PATH", "/nonexistent") + with pytest.raises(ValidationError) as exc: + resolve_m2pack_binary() + assert exc.value.error_code == "m2pack_not_found" + assert "M2PACK_BINARY" in str(exc.value) + + +def test_custom_env_mapping_parameter(tmp_path: Path): + stub = _make_stub(tmp_path) + fake_env = {ENV_VAR: str(stub)} + resolved = resolve_m2pack_binary(env=fake_env) + assert resolved == stub.resolve() diff --git a/tests/test_m2pack_commands.py b/tests/test_m2pack_commands.py new file mode 100644 index 0000000..da6eb9d --- /dev/null +++ b/tests/test_m2pack_commands.py @@ -0,0 +1,307 @@ +"""Tests for the m2pack wrapper subcommands. + +All tests use a stub binary — a small Python script that echoes a canned +JSON envelope based on the subcommand name — pointed at via the +``M2PACK_BINARY`` env var. The real m2pack-secure binary is never invoked. +""" + +from __future__ import annotations + +import json +import stat +import sys +from pathlib import Path + +import pytest + +from metin_release.cli import main as cli_main + + +STUB_TEMPLATE = r"""#!{python} +import json +import sys + +argv = sys.argv[1:] +sub = argv[0] if argv else "unknown" +MODE = {mode!r} + +if MODE == "fail": + sys.stderr.write("boom\n") + sys.exit(2) +if MODE == "nonjson": + sys.stdout.write("not json at all\n") + sys.exit(0) +if MODE == "notobject": + sys.stdout.write(json.dumps([1, 2, 3]) + "\n") + sys.exit(0) + +# mode == "ok": echo a canned envelope per subcommand +if sub == "build": + env = {{"ok": True, "command": "build", "stats": {{"files": 3, "bytes": 12345}}}} +elif sub == "verify": + env = {{"ok": True, "command": "verify", "signature": "valid"}} +elif sub == "diff": + env = {{"ok": True, "command": "diff", "added": ["a"], "removed": [], "changed": ["b", "c"], "unchanged": 7}} +elif sub == "export-runtime-key": + env = {{"ok": True, "command": "export-runtime-key", "key_id": 1, "format": "json"}} +else: + env = {{"ok": True, "command": sub}} + +# Record argv so the test can assert translation +import os +log = os.environ.get("M2PACK_STUB_LOG") +if log: + with open(log, "a") as fh: + fh.write(json.dumps(argv) + "\n") + +sys.stdout.write(json.dumps(env) + "\n") +sys.exit(0) +""" + + +def _install_stub(tmp_path: Path, monkeypatch, mode: str = "ok") -> tuple[Path, Path]: + stub = tmp_path / "m2pack_stub.py" + stub.write_text(STUB_TEMPLATE.format(python=sys.executable, mode=mode)) + stub.chmod(stub.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + log = tmp_path / "stub.log" + monkeypatch.setenv("M2PACK_BINARY", str(stub)) + monkeypatch.setenv("M2PACK_STUB_LOG", str(log)) + return stub, log + + +def _run_cli(argv: list[str], capsys) -> dict: + rc = cli_main(argv) + out = capsys.readouterr().out + envelope = json.loads(out) + return {"rc": rc, "env": envelope} + + +def _read_stub_call(log: Path) -> list[str]: + lines = [ln for ln in log.read_text().splitlines() if ln.strip()] + assert lines, "stub was never invoked" + return json.loads(lines[-1]) + + +# --------------------------------------------------------------------------- +# Success paths +# --------------------------------------------------------------------------- + + +def test_build_success(tmp_path, monkeypatch, capsys): + _, log = _install_stub(tmp_path, monkeypatch) + (tmp_path / "in").mkdir() + key = tmp_path / "k.hex" + key.write_text("ff") + sk = tmp_path / "sk.hex" + sk.write_text("aa") + out = tmp_path / "out.m2p" + + r = _run_cli( + [ + "--json", + "m2pack", + "build", + "--input", str(tmp_path / "in"), + "--output", str(out), + "--key", str(key), + "--sign-secret-key", str(sk), + "--key-id", "7", + ], + capsys, + ) + assert r["rc"] == 0 + assert r["env"]["ok"] is True + assert r["env"]["command"] == "m2pack build" + assert r["env"]["status"] == "built" + assert r["env"]["artifacts"]["archive_path"] == str(out) + assert r["env"]["m2pack"]["stats"]["files"] == 3 + + call = _read_stub_call(log) + assert call[0] == "build" + assert "--input" in call and str(tmp_path / "in") in call + assert "--output" in call and str(out) in call + assert "--key" in call and str(key) in call + assert "--sign-secret-key" in call and str(sk) in call + assert "--key-id" in call and "7" in call + assert "--json" in call + + +def test_build_omits_optional_key_id(tmp_path, monkeypatch, capsys): + _, log = _install_stub(tmp_path, monkeypatch) + (tmp_path / "in").mkdir() + (tmp_path / "k").write_text("a") + (tmp_path / "sk").write_text("b") + _run_cli( + [ + "--json", "m2pack", "build", + "--input", str(tmp_path / "in"), + "--output", str(tmp_path / "o.m2p"), + "--key", str(tmp_path / "k"), + "--sign-secret-key", str(tmp_path / "sk"), + ], + capsys, + ) + call = _read_stub_call(log) + assert "--key-id" not in call + + +def test_verify_success_without_keys(tmp_path, monkeypatch, capsys): + _, log = _install_stub(tmp_path, monkeypatch) + archive = tmp_path / "a.m2p" + archive.write_bytes(b"x") + r = _run_cli( + ["--json", "m2pack", "verify", "--archive", str(archive)], capsys + ) + assert r["rc"] == 0 + assert r["env"]["status"] == "verified" + assert r["env"]["m2pack"]["signature"] == "valid" + call = _read_stub_call(log) + assert call[0] == "verify" + assert "--public-key" not in call + assert "--key" not in call + + +def test_verify_with_public_and_content_keys(tmp_path, monkeypatch, capsys): + _, log = _install_stub(tmp_path, monkeypatch) + archive = tmp_path / "a.m2p" + archive.write_bytes(b"x") + pk = tmp_path / "pub" + pk.write_text("ff") + ck = tmp_path / "ck" + ck.write_text("aa") + _run_cli( + [ + "--json", "m2pack", "verify", + "--archive", str(archive), + "--public-key", str(pk), + "--key", str(ck), + ], + capsys, + ) + call = _read_stub_call(log) + assert "--public-key" in call and str(pk) in call + assert "--key" in call and str(ck) in call + + +def test_diff_success_promotes_counts(tmp_path, monkeypatch, capsys): + _, log = _install_stub(tmp_path, monkeypatch) + left = tmp_path / "L" + left.mkdir() + right = tmp_path / "R.m2p" + right.write_bytes(b"x") + r = _run_cli( + [ + "--json", "m2pack", "diff", + "--left", str(left), + "--right", str(right), + ], + capsys, + ) + assert r["rc"] == 0 + assert r["env"]["status"] == "diffed" + stats = r["env"]["stats"] + assert stats["added_count"] == 1 + assert stats["removed_count"] == 0 + assert stats["changed_count"] == 2 + assert stats["unchanged_count"] == 7 + call = _read_stub_call(log) + assert call[0] == "diff" + + +def test_export_runtime_key_success(tmp_path, monkeypatch, capsys): + _, log = _install_stub(tmp_path, monkeypatch) + key = tmp_path / "ck" + key.write_text("aa") + pk = tmp_path / "pk" + pk.write_text("ff") + out = tmp_path / "runtime.json" + r = _run_cli( + [ + "--json", "m2pack", "export-runtime-key", + "--key", str(key), + "--public-key", str(pk), + "--output", str(out), + "--key-id", "3", + "--format", "blob", + ], + capsys, + ) + assert r["rc"] == 0 + assert r["env"]["status"] == "exported" + assert r["env"]["artifacts"]["runtime_key_path"] == str(out) + call = _read_stub_call(log) + assert call[0] == "export-runtime-key" + assert "--format" in call and "blob" in call + assert "--key-id" in call and "3" in call + + +def test_export_runtime_key_rejects_bad_format(tmp_path, monkeypatch, capsys): + _install_stub(tmp_path, monkeypatch) + with pytest.raises(SystemExit): + cli_main( + [ + "--json", "m2pack", "export-runtime-key", + "--key", str(tmp_path / "k"), + "--public-key", str(tmp_path / "p"), + "--output", str(tmp_path / "o"), + "--format", "yaml", + ] + ) + + +# --------------------------------------------------------------------------- +# Error paths +# --------------------------------------------------------------------------- + + +def test_nonzero_exit_maps_to_subprocess_failed(tmp_path, monkeypatch, capsys): + _install_stub(tmp_path, monkeypatch, mode="fail") + (tmp_path / "in").mkdir() + (tmp_path / "k").write_text("a") + (tmp_path / "sk").write_text("b") + rc = cli_main( + [ + "--json", "m2pack", "build", + "--input", str(tmp_path / "in"), + "--output", str(tmp_path / "o.m2p"), + "--key", str(tmp_path / "k"), + "--sign-secret-key", str(tmp_path / "sk"), + ], + ) + assert rc == 1 + env = json.loads(capsys.readouterr().out) + assert env["ok"] is False + assert env["error"]["code"] == "m2pack_failed" + + +def test_nonjson_output_maps_to_invalid_json(tmp_path, monkeypatch, capsys): + _install_stub(tmp_path, monkeypatch, mode="nonjson") + archive = tmp_path / "a.m2p" + archive.write_bytes(b"x") + rc = cli_main(["--json", "m2pack", "verify", "--archive", str(archive)]) + assert rc == 1 + env = json.loads(capsys.readouterr().out) + assert env["ok"] is False + assert env["error"]["code"] == "m2pack_invalid_json" + + +def test_json_array_output_maps_to_invalid_json(tmp_path, monkeypatch, capsys): + _install_stub(tmp_path, monkeypatch, mode="notobject") + archive = tmp_path / "a.m2p" + archive.write_bytes(b"x") + rc = cli_main(["--json", "m2pack", "verify", "--archive", str(archive)]) + assert rc == 1 + env = json.loads(capsys.readouterr().out) + assert env["error"]["code"] == "m2pack_invalid_json" + + +def test_missing_binary_raises_validation_error(tmp_path, monkeypatch, capsys): + monkeypatch.delenv("M2PACK_BINARY", raising=False) + monkeypatch.setenv("PATH", "/nonexistent") + archive = tmp_path / "a.m2p" + archive.write_bytes(b"x") + rc = cli_main(["--json", "m2pack", "verify", "--archive", str(archive)]) + assert rc == 1 + env = json.loads(capsys.readouterr().out) + assert env["ok"] is False + assert env["error"]["code"] == "m2pack_not_found" From 22f67a0589422871e5efd25032800d793da050ad Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 22:31:16 +0200 Subject: [PATCH 7/7] docs: document m2pack subcommands Add a Phase 4 'm2pack commands' section to docs/cli.md with each subcommand's flags and a pointer at the m2pack-secure repo for installation. Update README.md with a short m2pack paragraph and append the Phase 4 entry to the Unreleased CHANGELOG section. --- CHANGELOG.md | 3 +++ README.md | 14 ++++++++++ docs/cli.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4db8281..ad66862 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ contract. ### Added +- `metin-release m2pack ` subcommands that wrap the m2pack-secure binary. +- `m2pack_build`, `m2pack_verify`, `m2pack_diff`, `m2pack_export_runtime_key` MCP tools mirroring the CLI surface. +- `src/metin_release/m2pack_binary.py` binary-discovery helper using `M2PACK_BINARY` env var or `PATH`. - `metin_release_mcp` package and `metin-release-mcp` console script — a thin Model Context Protocol stdio server that wraps the Phase 1 `release …` subcommands as eight MCP tools (`release_inspect`, diff --git a/README.md b/README.md index e40c6c9..0499273 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,20 @@ Add `--json` to get a machine-parseable envelope on stdout. Exit codes: See `docs/cli.md` for the full command reference. +## m2pack commands + +Phase 4 adds a `metin-release m2pack …` command group that wraps the +[`m2pack-secure`](https://gitea.jakubkadlec.dev/metin-server/m2pack-secure) +binary for building, verifying, diffing, and runtime-key-exporting +signed `.m2p` archives. The binary is not bundled — build it from the +m2pack-secure repo and either put it on `PATH` or set `M2PACK_BINARY` +to an absolute path. + +``` +metin-release m2pack build --input ... --output a.m2p --key ck --sign-secret-key sk +metin-release m2pack verify --archive a.m2p --public-key pk +``` + ## MCP server The `metin-release-mcp` console script (Phase 3) exposes each Phase 1 diff --git a/docs/cli.md b/docs/cli.md index 42f2f00..e3a5ebb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,6 +1,7 @@ # metin-release — CLI reference -Phase 1 commands. All subcommands share the top-level flags. +Phase 1 `release …` commands and Phase 4 `m2pack …` commands. All +subcommands share the top-level flags. ## Top-level flags @@ -145,3 +146,75 @@ metin-release release publish \ [--created-at ...] [--sample-blobs N] \ [--yes] [--force] [--dry-run-upload] ``` + +## m2pack commands + +Phase 4 subcommands wrap the `m2pack-secure` binary and translate its +JSON envelopes into the standard metin-release result envelope. The +binary is **not** shipped with this CLI — build it from +[`metin-server/m2pack-secure`](https://gitea.jakubkadlec.dev/metin-server/m2pack-secure) +and either put it on `PATH` or point at it via the `M2PACK_BINARY` +environment variable. + +All m2pack commands pass through `--json` to the real tool, so the +raw m2pack envelope is always available under `data.m2pack`. When +m2pack exits non-zero or emits non-JSON output the wrapper raises a +subprocess error with `m2pack_failed` / `m2pack_invalid_json` / +`m2pack_empty_output` error codes. + +### `m2pack build` + +Build a signed `.m2p` archive from a client asset directory. + +``` +metin-release m2pack build \ + --input /path/to/client-assets \ + --output /path/to/out.m2p \ + --key /path/to/content.key \ + --sign-secret-key /path/to/signing.sk \ + [--key-id N] +``` + +### `m2pack verify` + +Verify an `.m2p` archive's signature (and optionally full-decrypt it). + +``` +metin-release m2pack verify \ + --archive /path/to/a.m2p \ + [--public-key /path/to/signing.pub] \ + [--key /path/to/content.key] +``` + +Passing `--key` enables full-decrypt verification; omitting it only +checks manifest structure and signature. + +### `m2pack diff` + +Diff two directories and/or `.m2p` archives. Either side can be a +directory or an archive; m2pack figures it out. + +``` +metin-release m2pack diff --left /old --right /new.m2p +``` + +The wrapper promotes m2pack's added/removed/changed/unchanged counts +into `data.stats` when available. + +### `m2pack export-runtime-key` + +Export a launcher runtime-key payload (json or raw blob) from a master +content key + signing public key. Used to seed the launcher's bundled +runtime-key file during release workflows. + +``` +metin-release m2pack export-runtime-key \ + --key /path/to/content.key \ + --public-key /path/to/signing.pub \ + --output /path/to/runtime-key.json \ + [--key-id N] [--format json|blob] +``` + +See `docs/key-rotation.md` in `m2pack-secure` for when to re-export +runtime keys. +