Compare commits

8 Commits

Author SHA1 Message Date
Jan Nedbal
5c32224ff0 Merge branch 'claude/phase-4-m2pack'
Add Phase 4 m2pack CLI + MCP wrapping. New top-level 'metin-release
m2pack' command group with build, verify, diff, and export-runtime-key
subcommands that shell out to the real m2pack-secure binary (resolved
via M2PACK_BINARY env or PATH). Each wrapper translates m2pack's own
JSON envelope into the canonical Result shape, keeping the raw m2pack
output under data.m2pack for callers that need the untranslated version.
The MCP server exposes the same four as m2pack_* tools via the existing
ToolSpec catalogue; all 12 tools (8 release_* + 4 m2pack_*) appear in
--list-tools and pass the parametrised schema mirror test.

93 tests green (71 -> 93), no new dependencies.
2026-04-14 22:33:13 +02:00
Jan Nedbal
22f67a0589 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.
2026-04-14 22:31:16 +02:00
Jan Nedbal
1201ec50d2 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.
2026-04-14 22:31:11 +02:00
Jan Nedbal
c4c65e2fe7 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.
2026-04-14 22:31:05 +02:00
Jan Nedbal
a289cd7c25 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.
2026-04-14 22:31:00 +02:00
Jan Nedbal
197c5ba8a2 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).
2026-04-14 22:30:56 +02:00
Jan Nedbal
ae0cbb7e9b 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.
2026-04-14 22:30:51 +02:00
Jan Nedbal
70d20f0f18 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.
2026-04-14 22:30:45 +02:00
16 changed files with 960 additions and 6 deletions

View File

@@ -11,6 +11,9 @@ contract.
### Added
- `metin-release m2pack <build|verify|diff|export-runtime-key>` 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`,

View File

@@ -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

View File

@@ -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.

View File

@@ -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="<command>")
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,
),
}

View File

@@ -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",

View File

@@ -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 <subcommand> [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

View File

@@ -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,
},
)

View File

@@ -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,
},
)

View File

@@ -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,
},
)

View File

@@ -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,
},
)

View File

@@ -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")

View File

@@ -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)."),
),
),
)

View File

@@ -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

View File

@@ -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():

View File

@@ -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()

View File

@@ -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"