Compare commits
9 Commits
claude/pha
...
5c32224ff0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c32224ff0 | ||
|
|
22f67a0589 | ||
|
|
1201ec50d2 | ||
|
|
c4c65e2fe7 | ||
|
|
a289cd7c25 | ||
|
|
197c5ba8a2 | ||
|
|
ae0cbb7e9b | ||
|
|
70d20f0f18 | ||
|
|
50ea80d64b |
@@ -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