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.
This commit is contained in:
@@ -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`,
|
||||
|
||||
14
README.md
14
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
|
||||
|
||||
75
docs/cli.md
75
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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
78
src/metin_release/commands/_m2pack_runner.py
Normal file
78
src/metin_release/commands/_m2pack_runner.py
Normal 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
|
||||
47
src/metin_release/commands/m2pack_build.py
Normal file
47
src/metin_release/commands/m2pack_build.py
Normal 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,
|
||||
},
|
||||
)
|
||||
45
src/metin_release/commands/m2pack_diff.py
Normal file
45
src/metin_release/commands/m2pack_diff.py
Normal 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,
|
||||
},
|
||||
)
|
||||
57
src/metin_release/commands/m2pack_export_runtime_key.py
Normal file
57
src/metin_release/commands/m2pack_export_runtime_key.py
Normal 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,
|
||||
},
|
||||
)
|
||||
45
src/metin_release/commands/m2pack_verify.py
Normal file
45
src/metin_release/commands/m2pack_verify.py
Normal 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,
|
||||
},
|
||||
)
|
||||
51
src/metin_release/m2pack_binary.py
Normal file
51
src/metin_release/m2pack_binary.py
Normal 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")
|
||||
@@ -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)."),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
68
tests/test_m2pack_binary.py
Normal file
68
tests/test_m2pack_binary.py
Normal 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()
|
||||
307
tests/test_m2pack_commands.py
Normal file
307
tests/test_m2pack_commands.py
Normal 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"
|
||||
Reference in New Issue
Block a user