Compare commits
11 Commits
fb1e0cda87
...
claude/pha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2283b0c3f | ||
|
|
a8ae85c0a4 | ||
|
|
79df538226 | ||
|
|
7117d25a9a | ||
|
|
860face1d1 | ||
|
|
d55291e75e | ||
|
|
6e71ddb635 | ||
|
|
362bd6ae7c | ||
|
|
e4f91c9cd0 | ||
|
|
d2dd2c88b6 | ||
|
|
e70fc300e2 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -101,6 +101,12 @@ ipython_config.py
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# project-local
|
||||
/out/
|
||||
/staging/
|
||||
/.venv/
|
||||
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
|
||||
71
CHANGELOG.md
Normal file
71
CHANGELOG.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to `metin-release-cli` are documented here.
|
||||
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions
|
||||
follow semantic versioning: major for incompatible CLI or JSON contract changes,
|
||||
minor for new subcommands or additive flags, patch for fixes that preserve the
|
||||
contract.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- `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`,
|
||||
`release_build_manifest`, `release_sign`, `release_diff_remote`,
|
||||
`release_upload_blobs`, `release_promote`, `release_verify_public`,
|
||||
`release_publish`). Each tool's input schema mirrors the CLI's
|
||||
argparse signature 1:1; the wrapper shells out to
|
||||
`metin-release <subcommand> --json …` and returns the parsed envelope
|
||||
verbatim with zero duplicated business logic.
|
||||
- `[mcp]` optional dependency group pulling in the official `mcp`
|
||||
Python SDK.
|
||||
- `docs/mcp.md` — MCP server usage guide.
|
||||
- `tests/mcp/` — 45 new tests covering the CLI runner (success path,
|
||||
error-envelope passthrough, unparseable output, missing binary),
|
||||
tool schemas (mirror checks against each command's argparse), and
|
||||
dispatch translation (booleans, optionals, paths with spaces).
|
||||
|
||||
## [0.1.0] - 2026-04-14
|
||||
|
||||
First Phase 1 drop. Implements the minimum asset release path the
|
||||
`metin-release-cli-plan.md` calls for — everything the launcher contract needs
|
||||
to publish a signed manifest and content-addressed blob tree.
|
||||
|
||||
### Added
|
||||
|
||||
- `metin-release` command, `python -m metin_release` module entry, and
|
||||
`metin-release --version` resolving via `importlib.metadata`
|
||||
- Top-level and per-subcommand `--json`, `-v`, `-q` flags (both placements work)
|
||||
- Structured result envelope and exit-code contract (`0/1/2/3/4`) from the plan
|
||||
- `release inspect` — scan a client source root and report counts + flags
|
||||
- `release build-manifest` — wrap `make-manifest.py` and report manifest stats
|
||||
- `release sign` — wrap `sign-manifest.py` and verify 64-byte output
|
||||
- `release diff-remote` — HEAD blob URLs and report the missing set
|
||||
- `release upload-blobs` — rsync push blobs + archive to a target, with
|
||||
`--dry-run` and `--yes`, excluding `manifest.json*` so promote stays atomic
|
||||
- `release promote` — upload only `manifest.json` and `manifest.json.sig`
|
||||
- `release verify-public` — fetch + Ed25519-verify the published manifest,
|
||||
optional `--sample-blobs N` sha256 re-check
|
||||
- `release publish` — composite of build-manifest → sign → upload-blobs →
|
||||
promote → verify-public with per-stage timings and short-circuit on failure
|
||||
- Error hierarchy in `errors.py` mapping each exception class to the right
|
||||
exit code and a stable `error_code` string for machine parsing
|
||||
- pytest suite with 18 tests covering dispatch, inspect, build-manifest, sign,
|
||||
diff-remote (via a local HTTPServer fixture), and the full publish composite
|
||||
against a local rsync target
|
||||
- `docs/cli.md` man-page-style reference
|
||||
|
||||
### Notes
|
||||
|
||||
- Runtime deps pinned to `cryptography` and `requests` only. Dev deps are
|
||||
`pytest` + `pytest-mock`.
|
||||
- `make-manifest.py` and `sign-manifest.py` are discovered by convention at
|
||||
`scripts/make-manifest.py` / `scripts/sign-manifest.py` relative to the CLI
|
||||
repo, or overridden via `METIN_RELEASE_MAKE_MANIFEST` and
|
||||
`METIN_RELEASE_SIGN_MANIFEST` env vars.
|
||||
- Phase 2 (`erp …` subcommands), Phase 3 (MCP wrapper), Phase 4 (`m2pack …`
|
||||
subcommands) and Phase 5 (`launcher publish`) are explicitly out of scope for
|
||||
this release and will land in future minors.
|
||||
44
README.md
44
README.md
@@ -1,3 +1,45 @@
|
||||
# metin-release-cli
|
||||
|
||||
Python CLI for orchestrating Metin2 client releases (Phase 1 of metin-release-cli-plan)
|
||||
Python CLI that orchestrates Metin2 client releases — builds manifests, signs
|
||||
them, uploads content-addressed blobs, and promotes the new release atomically.
|
||||
|
||||
Phase 1 wraps `make-manifest.py` and `sign-manifest.py` from the `m2dev-client`
|
||||
repo and adds remote diff, upload, promote, and public verification.
|
||||
|
||||
## Install
|
||||
|
||||
```
|
||||
pip install -e .[dev]
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
metin-release --help
|
||||
metin-release release inspect --source /path/to/client
|
||||
metin-release release publish --source ... --version 2026.04.14-1 ...
|
||||
```
|
||||
|
||||
Add `--json` to get a machine-parseable envelope on stdout. Exit codes:
|
||||
|
||||
| Code | Meaning |
|
||||
|------|--------------------------------------|
|
||||
| 0 | success |
|
||||
| 1 | operator / validation error |
|
||||
| 2 | remote or network error |
|
||||
| 3 | signing or integrity error |
|
||||
| 4 | reserved (ERP sync, Phase 2+) |
|
||||
|
||||
See `docs/cli.md` for the full command reference.
|
||||
|
||||
## MCP server
|
||||
|
||||
The `metin-release-mcp` console script (Phase 3) exposes each Phase 1
|
||||
subcommand as an MCP tool over stdio. Install with the `mcp` extra:
|
||||
|
||||
```
|
||||
pip install -e .[mcp]
|
||||
metin-release-mcp --help
|
||||
```
|
||||
|
||||
See `docs/mcp.md` for tool list, client wiring, and error handling.
|
||||
|
||||
147
docs/cli.md
Normal file
147
docs/cli.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# metin-release — CLI reference
|
||||
|
||||
Phase 1 commands. All subcommands share the top-level flags.
|
||||
|
||||
## Top-level flags
|
||||
|
||||
| Flag | Description |
|
||||
|---|---|
|
||||
| `--version` | Print package version and exit. |
|
||||
| `--json` | Emit only the JSON envelope on stdout (stderr may still carry logs). |
|
||||
| `-v`, `--verbose` | Verbose stderr logging. |
|
||||
| `-q`, `--quiet` | Suppress stderr logging entirely. |
|
||||
|
||||
## Output envelope
|
||||
|
||||
Every command writes a JSON envelope on stdout:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"command": "release inspect",
|
||||
"status": "inspected",
|
||||
"stats": { "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
On failure:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"command": "release inspect",
|
||||
"status": "failed",
|
||||
"error": { "code": "source_not_found", "message": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| 0 | Success |
|
||||
| 1 | Operator or validation error |
|
||||
| 2 | Remote / network error |
|
||||
| 3 | Signing or integrity error |
|
||||
| 4 | Reserved (ERP sync, Phase 2+) |
|
||||
|
||||
## `release inspect`
|
||||
|
||||
Scan a client source root and report file counts plus launcher/main-exe detection.
|
||||
|
||||
```
|
||||
metin-release release inspect --source /path/to/client
|
||||
```
|
||||
|
||||
No writes. JSON stats: `source_path`, `file_count`, `total_bytes`, `launcher_present`, `main_exe_present`.
|
||||
|
||||
## `release build-manifest`
|
||||
|
||||
Wraps `make-manifest.py` to produce `manifest.json` for a source tree.
|
||||
|
||||
```
|
||||
metin-release release build-manifest \
|
||||
--source /path/to/client \
|
||||
--version 2026.04.14-1 \
|
||||
--out /tmp/release/manifest.json \
|
||||
[--previous 2026.04.13-3] \
|
||||
[--notes release-notes.md] \
|
||||
[--launcher Metin2Launcher.exe] \
|
||||
[--created-at 2026-04-14T12:00:00Z]
|
||||
```
|
||||
|
||||
Override the wrapped script path via the `METIN_RELEASE_MAKE_MANIFEST` env var.
|
||||
|
||||
## `release sign`
|
||||
|
||||
Wraps `sign-manifest.py`. Requires an absolute path to a chmod-600 raw 32-byte Ed25519 key.
|
||||
|
||||
```
|
||||
metin-release release sign \
|
||||
--manifest /tmp/release/manifest.json \
|
||||
--key /home/you/.config/metin/launcher-signing-key \
|
||||
[--out /tmp/release/manifest.json.sig]
|
||||
```
|
||||
|
||||
Override the wrapped script path via `METIN_RELEASE_SIGN_MANIFEST`.
|
||||
|
||||
## `release diff-remote`
|
||||
|
||||
HEADs every unique blob hash from a manifest against `<base-url>/files/<hh>/<hash>`.
|
||||
|
||||
```
|
||||
metin-release release diff-remote \
|
||||
--manifest /tmp/release/manifest.json \
|
||||
--base-url https://updates.example.com
|
||||
```
|
||||
|
||||
## `release upload-blobs`
|
||||
|
||||
rsyncs the release directory (excluding `manifest.json` and `.sig`) to a target.
|
||||
|
||||
```
|
||||
metin-release release upload-blobs \
|
||||
--release-dir /tmp/release \
|
||||
--rsync-target user@host:/var/www/updates/ \
|
||||
[--dry-run] [--yes]
|
||||
```
|
||||
|
||||
## `release promote`
|
||||
|
||||
Pushes only `manifest.json` + `manifest.json.sig` to the target top-level — makes the new release live.
|
||||
|
||||
```
|
||||
metin-release release promote \
|
||||
--release-dir /tmp/release \
|
||||
--rsync-target user@host:/var/www/updates/ \
|
||||
[--dry-run] [--yes]
|
||||
```
|
||||
|
||||
## `release verify-public`
|
||||
|
||||
GETs `manifest.json` + `manifest.json.sig` from a public URL and verifies the Ed25519 signature. Optionally spot-checks random blobs with `--sample-blobs N`.
|
||||
|
||||
```
|
||||
metin-release release verify-public \
|
||||
--base-url https://updates.example.com \
|
||||
--public-key <hex-or-path-to-hex-file> \
|
||||
[--sample-blobs 5]
|
||||
```
|
||||
|
||||
## `release publish`
|
||||
|
||||
Composite: build-manifest → sign → stage blob tree → upload-blobs → promote → verify-public. Short-circuits on the first failure; the JSON envelope includes a `stages` array with `{name, status, duration_ms, error?}` per step.
|
||||
|
||||
```
|
||||
metin-release release publish \
|
||||
--source /path/to/client \
|
||||
--version 2026.04.14-1 \
|
||||
--out /tmp/release \
|
||||
--key /home/you/.config/metin/launcher-signing-key \
|
||||
--rsync-target user@host:/var/www/updates/ \
|
||||
--base-url https://updates.example.com \
|
||||
--public-key <hex-or-path-to-hex-file> \
|
||||
[--previous ...] [--notes ...] [--launcher ...] \
|
||||
[--created-at ...] [--sample-blobs N] \
|
||||
[--yes] [--force] [--dry-run-upload]
|
||||
```
|
||||
120
docs/mcp.md
Normal file
120
docs/mcp.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# metin-release-mcp
|
||||
|
||||
Thin [Model Context Protocol](https://modelcontextprotocol.io/) server that
|
||||
wraps the Phase 1 `metin-release` CLI. Each `release …` subcommand is exposed
|
||||
as an MCP tool. The server contains no release business logic: it shells out
|
||||
to the real CLI with `--json` and returns the parsed envelope verbatim.
|
||||
|
||||
## Install
|
||||
|
||||
The server ships as an optional extra alongside the main CLI:
|
||||
|
||||
```
|
||||
pip install -e '.[mcp]'
|
||||
```
|
||||
|
||||
This installs the `mcp` Python SDK and adds a `metin-release-mcp` console
|
||||
script plus a `python -m metin_release_mcp` module entry.
|
||||
|
||||
## Running
|
||||
|
||||
The server speaks MCP over stdio, so you wire it into an MCP-capable client
|
||||
(Claude Desktop, Claude Code, etc.) as a stdio command. Example client entry:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"metin-release": {
|
||||
"command": "metin-release-mcp",
|
||||
"env": {
|
||||
"METIN_RELEASE_BINARY": "/usr/local/bin/metin-release"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can also poke at it directly:
|
||||
|
||||
- `metin-release-mcp --help` — list registered tools
|
||||
- `metin-release-mcp --list-tools` — dump the full tool JSON schemas
|
||||
- `metin-release-mcp --version`
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | CLI subcommand |
|
||||
|---|---|
|
||||
| `release_inspect` | `metin-release release inspect` |
|
||||
| `release_build_manifest` | `metin-release release build-manifest` |
|
||||
| `release_sign` | `metin-release release sign` |
|
||||
| `release_diff_remote` | `metin-release release diff-remote` |
|
||||
| `release_upload_blobs` | `metin-release release upload-blobs` |
|
||||
| `release_promote` | `metin-release release promote` |
|
||||
| `release_verify_public` | `metin-release release verify-public` |
|
||||
| `release_publish` | `metin-release release publish` |
|
||||
|
||||
Tool input keys match CLI flag names with `_` instead of `-`
|
||||
(`--base-url` → `base_url`, `--dry-run` → `dry_run`). Boolean fields
|
||||
correspond to argparse `store_true` flags: pass `true` to set the flag,
|
||||
omit or pass `false` to leave it off.
|
||||
|
||||
### Example invocation
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "release_inspect",
|
||||
"arguments": {
|
||||
"source": "/srv/metin/client"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The server runs `metin-release release inspect --json --source /srv/metin/client`
|
||||
and returns the full JSON envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"command": "release inspect",
|
||||
"status": "inspected",
|
||||
"stats": {
|
||||
"source_path": "/srv/metin/client",
|
||||
"file_count": 9166,
|
||||
"total_bytes": 3523473920,
|
||||
"launcher_present": true,
|
||||
"main_exe_present": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## CLI resolution
|
||||
|
||||
On every tool call the server resolves the `metin-release` binary in this order:
|
||||
|
||||
1. `METIN_RELEASE_BINARY` environment variable, if set and non-empty
|
||||
2. `shutil.which("metin-release")` against `PATH`
|
||||
|
||||
If neither resolves, the tool call returns a wrapper-level error envelope
|
||||
(`error.code = "cli_not_found"`).
|
||||
|
||||
## Error handling
|
||||
|
||||
The server never invents its own release-level errors. There are three paths:
|
||||
|
||||
- **Success** — CLI exits 0 with a valid JSON envelope → envelope returned as-is
|
||||
- **CLI-level failure** — CLI exits non-zero with an `{"ok": false, "error": …}`
|
||||
envelope → that envelope is returned as-is, plus the CLI's stderr is
|
||||
attached as a diagnostic text block
|
||||
- **Wrapper failure** — binary missing, unparseable stdout, unknown tool,
|
||||
invalid input → synthetic envelope with one of
|
||||
`cli_not_found`, `cli_unparseable_output`, `unknown_tool`,
|
||||
`invalid_tool_input`
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `METIN_RELEASE_BINARY` | Override the `metin-release` binary path. |
|
||||
|
||||
Any env vars the wrapped CLI honours (`METIN_RELEASE_MAKE_MANIFEST`,
|
||||
`METIN_RELEASE_SIGN_MANIFEST`) are inherited by the subprocess unchanged.
|
||||
46
pyproject.toml
Normal file
46
pyproject.toml
Normal file
@@ -0,0 +1,46 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "metin-release-cli"
|
||||
version = "0.1.0"
|
||||
description = "Orchestration CLI for Metin2 client releases"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [
|
||||
{ name = "Jan Nedbal", email = "jan.nedbal@apertia.cz" },
|
||||
]
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"cryptography>=41",
|
||||
"requests>=2.31",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8",
|
||||
"pytest-mock>=3",
|
||||
]
|
||||
mcp = [
|
||||
"mcp>=1.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
metin-release = "metin_release.cli:main"
|
||||
metin-release-mcp = "metin_release_mcp.server:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-q"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "B", "UP"]
|
||||
ignore = ["E501"]
|
||||
15
src/metin_release/__init__.py
Normal file
15
src/metin_release/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""metin-release: orchestration CLI for Metin2 client releases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
try:
|
||||
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
||||
except ImportError: # pragma: no cover
|
||||
from importlib_metadata import PackageNotFoundError, version as _pkg_version # type: ignore
|
||||
|
||||
try:
|
||||
__version__ = _pkg_version("metin-release-cli")
|
||||
except PackageNotFoundError: # pragma: no cover - running from source tree
|
||||
__version__ = "0.0.0+local"
|
||||
|
||||
__all__ = ["__version__"]
|
||||
6
src/metin_release/__main__.py
Normal file
6
src/metin_release/__main__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
132
src/metin_release/cli.py
Normal file
132
src/metin_release/cli.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Argparse dispatcher for metin-release."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from typing import Callable
|
||||
|
||||
from . import __version__
|
||||
from .commands import (
|
||||
build_manifest,
|
||||
diff_remote,
|
||||
inspect,
|
||||
promote,
|
||||
publish,
|
||||
sign,
|
||||
upload_blobs,
|
||||
verify_public,
|
||||
)
|
||||
from .errors import ReleaseError
|
||||
from .log import configure_logging, get_logger
|
||||
from .result import Result, write_result
|
||||
from .workspace import Context
|
||||
|
||||
|
||||
CommandFn = Callable[[Context, argparse.Namespace], Result]
|
||||
|
||||
|
||||
def _add_common_flags(p: argparse.ArgumentParser) -> None:
|
||||
"""Attach --json / -v / -q to a parser.
|
||||
|
||||
These flags are accepted both at the top level and on each subcommand so
|
||||
that users can write either `metin-release --json release inspect ...` or
|
||||
the more common `metin-release release inspect ... --json`. The shared
|
||||
`dest` means whichever occurrence argparse sees last wins, which is the
|
||||
intuitive "last flag on the command line" behaviour.
|
||||
"""
|
||||
p.add_argument("--json", action="store_true", help="Emit only JSON on stdout.")
|
||||
p.add_argument("-v", "--verbose", action="store_true", help="Verbose stderr logging.")
|
||||
p.add_argument("-q", "--quiet", action="store_true", help="Suppress stderr logging.")
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="metin-release",
|
||||
description="Orchestration CLI for Metin2 client releases.",
|
||||
)
|
||||
parser.add_argument("--version", action="version", version=f"metin-release {__version__}")
|
||||
_add_common_flags(parser)
|
||||
|
||||
sub = parser.add_subparsers(dest="group", metavar="<group>")
|
||||
sub.required = True
|
||||
|
||||
release = sub.add_parser("release", help="Asset release commands.")
|
||||
rsub = release.add_subparsers(dest="cmd", metavar="<command>")
|
||||
rsub.required = True
|
||||
|
||||
for mod in (
|
||||
inspect,
|
||||
build_manifest,
|
||||
sign,
|
||||
diff_remote,
|
||||
upload_blobs,
|
||||
promote,
|
||||
verify_public,
|
||||
publish,
|
||||
):
|
||||
sp = mod.add_parser(rsub)
|
||||
_add_common_flags(sp)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
_COMMAND_MAP: dict[tuple[str, str], tuple[str, CommandFn]] = {
|
||||
("release", "inspect"): ("release inspect", inspect.run),
|
||||
("release", "build-manifest"): ("release build-manifest", build_manifest.run),
|
||||
("release", "sign"): ("release sign", sign.run),
|
||||
("release", "diff-remote"): ("release diff-remote", diff_remote.run),
|
||||
("release", "upload-blobs"): ("release upload-blobs", upload_blobs.run),
|
||||
("release", "promote"): ("release promote", promote.run),
|
||||
("release", "verify-public"): ("release verify-public", verify_public.run),
|
||||
("release", "publish"): ("release publish", publish.run),
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
configure_logging(verbose=args.verbose, quiet=args.quiet)
|
||||
logger = get_logger()
|
||||
|
||||
ctx = Context(json_mode=args.json, verbose=args.verbose, quiet=args.quiet)
|
||||
|
||||
key = (args.group, args.cmd)
|
||||
if key not in _COMMAND_MAP:
|
||||
parser.error(f"unknown command: {args.group} {args.cmd}")
|
||||
return 1 # unreachable; parser.error exits 2
|
||||
|
||||
command_name, fn = _COMMAND_MAP[key]
|
||||
|
||||
try:
|
||||
result = fn(ctx, args)
|
||||
except ReleaseError as exc:
|
||||
logger.error("%s: %s", exc.error_code, exc.message)
|
||||
result = Result(
|
||||
command=command_name,
|
||||
ok=False,
|
||||
status="failed",
|
||||
version=getattr(args, "version", None) if hasattr(args, "version") else None,
|
||||
error_code=exc.error_code,
|
||||
error_message=exc.message,
|
||||
)
|
||||
write_result(result, json_mode=ctx.json_mode)
|
||||
return exc.exit_code
|
||||
except KeyboardInterrupt:
|
||||
print("interrupted", file=sys.stderr)
|
||||
return 130
|
||||
|
||||
write_result(
|
||||
result,
|
||||
json_mode=ctx.json_mode,
|
||||
human_summary=_human_summary(result),
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _human_summary(result: Result) -> str:
|
||||
parts = [f"[{result.command}] {result.status}"]
|
||||
if result.version:
|
||||
parts.append(f"version={result.version}")
|
||||
return " ".join(parts)
|
||||
23
src/metin_release/commands/__init__.py
Normal file
23
src/metin_release/commands/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from . import (
|
||||
build_manifest,
|
||||
diff_remote,
|
||||
inspect,
|
||||
promote,
|
||||
publish,
|
||||
sign,
|
||||
upload_blobs,
|
||||
verify_public,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"build_manifest",
|
||||
"diff_remote",
|
||||
"inspect",
|
||||
"promote",
|
||||
"publish",
|
||||
"sign",
|
||||
"upload_blobs",
|
||||
"verify_public",
|
||||
]
|
||||
103
src/metin_release/commands/build_manifest.py
Normal file
103
src/metin_release/commands/build_manifest.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""release build-manifest: wraps make-manifest.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from ..errors import SourceNotFoundError, SubprocessError, ValidationError
|
||||
from ..log import get_logger
|
||||
from ..result import Result
|
||||
from ..workspace import Context, ensure_dir, resolve_source
|
||||
|
||||
|
||||
def add_parser(sub: argparse._SubParsersAction) -> argparse.ArgumentParser:
|
||||
p = sub.add_parser("build-manifest", help="Build a signed-ready manifest.json for a source tree.")
|
||||
p.add_argument("--source", required=True, type=Path, help="Client source root.")
|
||||
p.add_argument("--version", required=True, help="Release version, e.g. 2026.04.14-1.")
|
||||
p.add_argument("--out", required=True, type=Path, help="Output manifest.json path.")
|
||||
p.add_argument("--previous", help="Previous release version, if any.")
|
||||
p.add_argument("--notes", type=Path, help="Path to a file containing release notes.")
|
||||
p.add_argument("--launcher", default="Metin2Launcher.exe", help="Launcher filename.")
|
||||
p.add_argument("--created-at", help="Override created_at (for reproducible test runs).")
|
||||
return p
|
||||
|
||||
|
||||
def run(ctx: Context, args: argparse.Namespace) -> Result:
|
||||
log = get_logger()
|
||||
source = resolve_source(args.source)
|
||||
if not source.is_dir():
|
||||
raise SourceNotFoundError(f"source is not a directory: {source}")
|
||||
|
||||
script = ctx.make_manifest_script
|
||||
if not script.is_file():
|
||||
raise ValidationError(f"make-manifest.py not found at {script} (set METIN_RELEASE_MAKE_MANIFEST)")
|
||||
|
||||
out_path = Path(args.out).expanduser().resolve()
|
||||
ensure_dir(out_path.parent)
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(script),
|
||||
"--source", str(source),
|
||||
"--version", args.version,
|
||||
"--out", str(out_path),
|
||||
"--launcher", args.launcher,
|
||||
]
|
||||
if args.previous:
|
||||
cmd += ["--previous", args.previous]
|
||||
if args.created_at:
|
||||
cmd += ["--created-at", args.created_at]
|
||||
|
||||
notes_text = None
|
||||
if args.notes:
|
||||
notes_path = Path(args.notes).expanduser().resolve()
|
||||
if not notes_path.is_file():
|
||||
raise ValidationError(f"notes file not found: {notes_path}")
|
||||
notes_text = notes_path.read_text(encoding="utf-8")
|
||||
cmd += ["--notes", notes_text]
|
||||
|
||||
log.debug("spawn %s", cmd)
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
except FileNotFoundError as exc:
|
||||
raise SubprocessError(f"cannot spawn make-manifest.py: {exc}") from exc
|
||||
|
||||
if proc.returncode != 0:
|
||||
raise SubprocessError(
|
||||
f"make-manifest.py exited {proc.returncode}: {proc.stderr.strip() or proc.stdout.strip()}"
|
||||
)
|
||||
if proc.stderr.strip():
|
||||
log.info("make-manifest: %s", proc.stderr.strip())
|
||||
|
||||
if not out_path.is_file():
|
||||
raise SubprocessError(f"manifest was not written to {out_path}")
|
||||
|
||||
manifest = json.loads(out_path.read_text(encoding="utf-8"))
|
||||
files = manifest.get("files", [])
|
||||
launcher = manifest.get("launcher", {})
|
||||
total_bytes = launcher.get("size", 0) + sum(int(f.get("size", 0)) for f in files)
|
||||
unique_blobs = {launcher.get("sha256")} | {f.get("sha256") for f in files}
|
||||
unique_blobs.discard(None)
|
||||
|
||||
return Result(
|
||||
command="release build-manifest",
|
||||
version=manifest.get("version"),
|
||||
status="manifest_built",
|
||||
data={
|
||||
"artifacts": {
|
||||
"manifest_path": str(out_path),
|
||||
},
|
||||
"stats": {
|
||||
"file_count": len(files) + 1,
|
||||
"blob_count": len(unique_blobs),
|
||||
"total_bytes": total_bytes,
|
||||
"version": manifest.get("version"),
|
||||
"created_at": manifest.get("created_at"),
|
||||
},
|
||||
},
|
||||
)
|
||||
83
src/metin_release/commands/diff_remote.py
Normal file
83
src/metin_release/commands/diff_remote.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""release diff-remote: HEAD every blob hash against a base URL."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from ..errors import NetworkError, ValidationError
|
||||
from ..log import get_logger
|
||||
from ..result import Result
|
||||
from ..workspace import Context
|
||||
|
||||
|
||||
def add_parser(sub: argparse._SubParsersAction) -> argparse.ArgumentParser:
|
||||
p = sub.add_parser("diff-remote", help="Check which manifest blobs are missing on the remote.")
|
||||
p.add_argument("--manifest", required=True, type=Path, help="Path to manifest.json.")
|
||||
p.add_argument("--base-url", required=True, help="Remote base URL, e.g. https://updates.example/")
|
||||
p.add_argument("--timeout", type=float, default=10.0, help="Per-request timeout in seconds.")
|
||||
return p
|
||||
|
||||
|
||||
def _blob_hashes(manifest: dict) -> list[str]:
|
||||
hashes: set[str] = set()
|
||||
launcher = manifest.get("launcher") or {}
|
||||
if launcher.get("sha256"):
|
||||
hashes.add(launcher["sha256"])
|
||||
for f in manifest.get("files", []):
|
||||
if f.get("sha256"):
|
||||
hashes.add(f["sha256"])
|
||||
return sorted(hashes)
|
||||
|
||||
|
||||
def _check_blob(session: requests.Session, base_url: str, h: str, timeout: float) -> bool:
|
||||
url = f"{base_url.rstrip('/')}/files/{h[:2]}/{h}"
|
||||
try:
|
||||
r = session.head(url, timeout=timeout, allow_redirects=True)
|
||||
except requests.RequestException as exc:
|
||||
raise NetworkError(f"HEAD {url} failed: {exc}") from exc
|
||||
if r.status_code == 200:
|
||||
return True
|
||||
if r.status_code == 404:
|
||||
return False
|
||||
raise NetworkError(f"HEAD {url} returned unexpected status {r.status_code}")
|
||||
|
||||
|
||||
def run(ctx: Context, args: argparse.Namespace) -> Result:
|
||||
log = get_logger()
|
||||
manifest_path = Path(args.manifest).expanduser().resolve()
|
||||
if not manifest_path.is_file():
|
||||
raise ValidationError(f"manifest not found: {manifest_path}")
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
|
||||
hashes = _blob_hashes(manifest)
|
||||
missing: list[str] = []
|
||||
with requests.Session() as session:
|
||||
for h in hashes:
|
||||
if not _check_blob(session, args.base_url, h, args.timeout):
|
||||
missing.append(h)
|
||||
log.info(
|
||||
"diff-remote: %d blobs total, %d missing on %s",
|
||||
len(hashes),
|
||||
len(missing),
|
||||
args.base_url,
|
||||
)
|
||||
|
||||
return Result(
|
||||
command="release diff-remote",
|
||||
version=manifest.get("version"),
|
||||
status="diffed",
|
||||
data={
|
||||
"remote": {
|
||||
"base_url": args.base_url,
|
||||
},
|
||||
"stats": {
|
||||
"manifest_blob_count": len(hashes),
|
||||
"missing_blob_count": len(missing),
|
||||
"missing_blobs": missing,
|
||||
},
|
||||
},
|
||||
)
|
||||
78
src/metin_release/commands/inspect.py
Normal file
78
src/metin_release/commands/inspect.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""release inspect: scan a source root, count files, detect launcher + main exe."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from ..errors import SourceNotFoundError
|
||||
from ..log import get_logger
|
||||
from ..result import Result
|
||||
from ..workspace import Context, resolve_source
|
||||
|
||||
|
||||
EXCLUDE_DIRS = {".git", ".vs", ".updates", "log", "__pycache__"}
|
||||
EXCLUDE_FILES = {".gitignore", ".gitattributes", "desktop.ini", "Thumbs.db", ".DS_Store"}
|
||||
EXCLUDE_SUFFIXES = {".pdb", ".ilk", ".old", ".log", ".dxvk-cache", ".swp", ".tmp"}
|
||||
|
||||
|
||||
def add_parser(sub: argparse._SubParsersAction) -> argparse.ArgumentParser:
|
||||
p = sub.add_parser("inspect", help="Scan a client source root and report stats.")
|
||||
p.add_argument("--source", required=True, type=Path, help="Client root directory.")
|
||||
return p
|
||||
|
||||
|
||||
def _should_skip(rel: Path) -> bool:
|
||||
for part in rel.parts:
|
||||
if part in EXCLUDE_DIRS:
|
||||
return True
|
||||
if rel.name in EXCLUDE_FILES:
|
||||
return True
|
||||
if rel.suffix in EXCLUDE_SUFFIXES:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def run(ctx: Context, args: argparse.Namespace) -> Result:
|
||||
log = get_logger()
|
||||
source = resolve_source(args.source)
|
||||
if not source.is_dir():
|
||||
raise SourceNotFoundError(f"source is not a directory: {source}")
|
||||
|
||||
file_count = 0
|
||||
total_bytes = 0
|
||||
launcher_present = False
|
||||
main_exe_present = False
|
||||
|
||||
for path in source.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
rel = path.relative_to(source)
|
||||
if _should_skip(rel):
|
||||
continue
|
||||
file_count += 1
|
||||
try:
|
||||
total_bytes += path.stat().st_size
|
||||
except OSError:
|
||||
log.warning("failed to stat %s", rel)
|
||||
name = rel.as_posix()
|
||||
if name == "Metin2Launcher.exe":
|
||||
launcher_present = True
|
||||
elif name == "Metin2.exe":
|
||||
main_exe_present = True
|
||||
|
||||
log.info("inspected %s: %d files, %d bytes", source, file_count, total_bytes)
|
||||
|
||||
return Result(
|
||||
command="release inspect",
|
||||
status="inspected",
|
||||
data={
|
||||
"stats": {
|
||||
"source_path": str(source),
|
||||
"file_count": file_count,
|
||||
"total_bytes": total_bytes,
|
||||
"launcher_present": launcher_present,
|
||||
"main_exe_present": main_exe_present,
|
||||
}
|
||||
},
|
||||
)
|
||||
52
src/metin_release/commands/promote.py
Normal file
52
src/metin_release/commands/promote.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""release promote: upload manifest.json + manifest.json.sig (small, fast)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from ..errors import ValidationError
|
||||
from ..log import get_logger
|
||||
from ..result import Result
|
||||
from ..storage import rsync as rsync_backend
|
||||
from ..workspace import Context
|
||||
|
||||
|
||||
def add_parser(sub: argparse._SubParsersAction) -> argparse.ArgumentParser:
|
||||
p = sub.add_parser("promote", help="Promote a staged release by pushing manifest + signature.")
|
||||
p.add_argument("--release-dir", required=True, type=Path, help="Local release directory containing manifest.json(.sig).")
|
||||
p.add_argument("--rsync-target", required=True, help="rsync destination top-level.")
|
||||
p.add_argument("--yes", action="store_true", help="Skip interactive confirmation.")
|
||||
p.add_argument("--dry-run", action="store_true", help="Run rsync --dry-run.")
|
||||
return p
|
||||
|
||||
|
||||
def run(ctx: Context, args: argparse.Namespace) -> Result:
|
||||
log = get_logger()
|
||||
release_dir = Path(args.release_dir).expanduser().resolve()
|
||||
manifest_path = release_dir / "manifest.json"
|
||||
if not manifest_path.is_file():
|
||||
raise ValidationError(f"manifest.json missing in {release_dir}")
|
||||
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
version = manifest.get("version")
|
||||
|
||||
rr = rsync_backend.push_manifest(release_dir, args.rsync_target, dry_run=args.dry_run)
|
||||
log.info("promote: version=%s rsync exit=%d", version, rr.returncode)
|
||||
|
||||
return Result(
|
||||
command="release promote",
|
||||
version=version,
|
||||
status="promoted" if not args.dry_run else "dry_run",
|
||||
data={
|
||||
"remote": {
|
||||
"rsync_target": args.rsync_target,
|
||||
"dry_run": args.dry_run,
|
||||
},
|
||||
"stats": {
|
||||
"release_dir": str(release_dir),
|
||||
"version": version,
|
||||
},
|
||||
},
|
||||
)
|
||||
186
src/metin_release/commands/publish.py
Normal file
186
src/metin_release/commands/publish.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""release publish: composite of build-manifest -> sign -> upload-blobs -> promote -> verify-public."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from ..errors import ReleaseError, ValidationError
|
||||
from ..log import get_logger
|
||||
from ..result import Result
|
||||
from ..workspace import Context, ensure_dir, resolve_source
|
||||
from . import build_manifest, promote, sign, upload_blobs, verify_public
|
||||
|
||||
|
||||
def add_parser(sub: argparse._SubParsersAction) -> argparse.ArgumentParser:
|
||||
p = sub.add_parser("publish", help="End-to-end: build, sign, upload, promote, verify.")
|
||||
p.add_argument("--source", required=True, type=Path, help="Client source root.")
|
||||
p.add_argument("--version", required=True, help="Release version.")
|
||||
p.add_argument("--out", required=True, type=Path, help="Release output directory.")
|
||||
p.add_argument("--key", required=True, type=Path, help="Signing key path (mode 600).")
|
||||
p.add_argument("--rsync-target", required=True, help="rsync target for blobs + manifest.")
|
||||
p.add_argument("--base-url", required=True, help="Public base URL for verification.")
|
||||
p.add_argument("--public-key", required=True, help="Public key (hex or file).")
|
||||
p.add_argument("--previous", help="Previous release version.")
|
||||
p.add_argument("--notes", type=Path, help="Release notes file.")
|
||||
p.add_argument("--launcher", default="Metin2Launcher.exe")
|
||||
p.add_argument("--created-at", help="Override manifest created_at.")
|
||||
p.add_argument("--sample-blobs", type=int, default=0)
|
||||
p.add_argument("--yes", action="store_true")
|
||||
p.add_argument("--force", action="store_true", help="Allow non-empty --out directory.")
|
||||
p.add_argument("--dry-run-upload", action="store_true", help="rsync --dry-run for upload and promote.")
|
||||
return p
|
||||
|
||||
|
||||
def _build_blob_tree(source: Path, manifest: dict, out_dir: Path) -> dict:
|
||||
files_dir = ensure_dir(out_dir / "files")
|
||||
entries = []
|
||||
launcher = manifest.get("launcher")
|
||||
if launcher:
|
||||
entries.append(launcher)
|
||||
entries.extend(manifest.get("files", []))
|
||||
|
||||
seen: set[str] = set()
|
||||
bytes_written = 0
|
||||
for entry in entries:
|
||||
h = entry["sha256"]
|
||||
rel = entry["path"]
|
||||
src = source / rel
|
||||
if not src.is_file():
|
||||
raise ValidationError(f"file in manifest missing from source: {rel}")
|
||||
if h in seen:
|
||||
continue
|
||||
seen.add(h)
|
||||
dst_dir = files_dir / h[:2]
|
||||
dst_dir.mkdir(parents=True, exist_ok=True)
|
||||
dst = dst_dir / h
|
||||
if dst.exists():
|
||||
continue
|
||||
try:
|
||||
dst.hardlink_to(src)
|
||||
except OSError:
|
||||
shutil.copy2(src, dst)
|
||||
bytes_written += dst.stat().st_size
|
||||
return {"unique_blobs": len(seen), "bytes_written": bytes_written}
|
||||
|
||||
|
||||
def _run_stage(name: str, fn: Callable[[], Result], stages: list[dict]) -> Result:
|
||||
start = time.monotonic()
|
||||
try:
|
||||
result = fn()
|
||||
except ReleaseError as exc:
|
||||
stages.append(
|
||||
{
|
||||
"name": name,
|
||||
"status": "failed",
|
||||
"duration_ms": int((time.monotonic() - start) * 1000),
|
||||
"error": {"code": exc.error_code, "message": exc.message},
|
||||
}
|
||||
)
|
||||
raise
|
||||
stages.append(
|
||||
{
|
||||
"name": name,
|
||||
"status": result.status,
|
||||
"duration_ms": int((time.monotonic() - start) * 1000),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def run(ctx: Context, args: argparse.Namespace) -> Result:
|
||||
log = get_logger()
|
||||
source = resolve_source(args.source)
|
||||
out_dir = Path(args.out).expanduser().resolve()
|
||||
ensure_dir(out_dir)
|
||||
|
||||
if any(out_dir.iterdir()) and not args.force:
|
||||
raise ValidationError(f"output directory {out_dir} is non-empty (use --force to overwrite)")
|
||||
|
||||
stages: list[dict] = []
|
||||
|
||||
manifest_path = out_dir / "manifest.json"
|
||||
|
||||
# Stage 1: build manifest
|
||||
bm_args = argparse.Namespace(
|
||||
source=source,
|
||||
version=args.version,
|
||||
out=manifest_path,
|
||||
previous=args.previous,
|
||||
notes=args.notes,
|
||||
launcher=args.launcher,
|
||||
created_at=args.created_at,
|
||||
)
|
||||
bm_result = _run_stage("build-manifest", lambda: build_manifest.run(ctx, bm_args), stages)
|
||||
|
||||
# Stage 2: sign
|
||||
sig_path = out_dir / "manifest.json.sig"
|
||||
sn_args = argparse.Namespace(manifest=manifest_path, key=args.key, out=sig_path)
|
||||
_run_stage("sign", lambda: sign.run(ctx, sn_args), stages)
|
||||
|
||||
# Stage 2.5: build blob tree (inline step, not its own public subcommand)
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
|
||||
def _blob_stage() -> Result:
|
||||
stats = _build_blob_tree(source, manifest, out_dir)
|
||||
return Result(command="release publish", status="blobs_staged", data={"stats": stats})
|
||||
|
||||
_run_stage("stage-blobs", _blob_stage, stages)
|
||||
|
||||
# Archive historical manifest
|
||||
manifests_dir = ensure_dir(out_dir / "manifests")
|
||||
(manifests_dir / f"{args.version}.json").write_bytes(manifest_path.read_bytes())
|
||||
(manifests_dir / f"{args.version}.json.sig").write_bytes(sig_path.read_bytes())
|
||||
|
||||
# Stage 3: upload blobs
|
||||
ub_args = argparse.Namespace(
|
||||
release_dir=out_dir,
|
||||
rsync_target=args.rsync_target,
|
||||
dry_run=args.dry_run_upload,
|
||||
yes=args.yes,
|
||||
)
|
||||
_run_stage("upload-blobs", lambda: upload_blobs.run(ctx, ub_args), stages)
|
||||
|
||||
# Stage 4: promote
|
||||
pr_args = argparse.Namespace(
|
||||
release_dir=out_dir,
|
||||
rsync_target=args.rsync_target,
|
||||
yes=args.yes,
|
||||
dry_run=args.dry_run_upload,
|
||||
)
|
||||
_run_stage("promote", lambda: promote.run(ctx, pr_args), stages)
|
||||
|
||||
# Stage 5: verify-public
|
||||
vp_args = argparse.Namespace(
|
||||
base_url=args.base_url,
|
||||
public_key=args.public_key,
|
||||
sample_blobs=args.sample_blobs,
|
||||
timeout=15.0,
|
||||
)
|
||||
vp_result = _run_stage("verify-public", lambda: verify_public.run(ctx, vp_args), stages)
|
||||
|
||||
log.info("publish: version=%s stages=%d", args.version, len(stages))
|
||||
|
||||
return Result(
|
||||
command="release publish",
|
||||
version=args.version,
|
||||
status="published",
|
||||
data={
|
||||
"artifacts": {
|
||||
"release_dir": str(out_dir),
|
||||
"manifest_path": str(manifest_path),
|
||||
"signature_path": str(sig_path),
|
||||
},
|
||||
"stats": {
|
||||
**bm_result.data.get("stats", {}),
|
||||
"verify": vp_result.data.get("stats", {}),
|
||||
},
|
||||
"stages": stages,
|
||||
},
|
||||
)
|
||||
104
src/metin_release/commands/sign.py
Normal file
104
src/metin_release/commands/sign.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""release sign: wraps sign-manifest.py with mode-600 enforcement."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from ..errors import (
|
||||
IntegrityError,
|
||||
KeyPermissionError,
|
||||
SubprocessError,
|
||||
ValidationError,
|
||||
)
|
||||
from ..log import get_logger
|
||||
from ..result import Result
|
||||
from ..workspace import Context
|
||||
|
||||
|
||||
def add_parser(sub: argparse._SubParsersAction) -> argparse.ArgumentParser:
|
||||
p = sub.add_parser("sign", help="Sign a manifest.json with an Ed25519 key.")
|
||||
p.add_argument("--manifest", required=True, type=Path, help="Path to manifest.json.")
|
||||
p.add_argument("--key", required=True, type=Path, help="Absolute path to raw 32-byte private key.")
|
||||
p.add_argument("--out", type=Path, help="Signature output path (default: <manifest>.sig).")
|
||||
return p
|
||||
|
||||
|
||||
def _enforce_key_mode(key_path: Path) -> None:
|
||||
if not key_path.is_absolute():
|
||||
raise ValidationError(f"--key must be an absolute path, got {key_path}")
|
||||
if not key_path.is_file():
|
||||
raise ValidationError(f"signing key not found: {key_path}")
|
||||
mode = stat.S_IMODE(key_path.stat().st_mode)
|
||||
if mode != 0o600:
|
||||
raise KeyPermissionError(
|
||||
f"signing key {key_path} must be mode 600, got {oct(mode)}"
|
||||
)
|
||||
|
||||
|
||||
def run(ctx: Context, args: argparse.Namespace) -> Result:
|
||||
log = get_logger()
|
||||
manifest_path = Path(args.manifest).expanduser().resolve()
|
||||
if not manifest_path.is_file():
|
||||
raise ValidationError(f"manifest not found: {manifest_path}")
|
||||
|
||||
key_path = Path(args.key).expanduser()
|
||||
_enforce_key_mode(key_path)
|
||||
|
||||
script = ctx.sign_manifest_script
|
||||
if not script.is_file():
|
||||
raise ValidationError(
|
||||
f"sign-manifest.py not found at {script} (set METIN_RELEASE_SIGN_MANIFEST)"
|
||||
)
|
||||
|
||||
out_path = (
|
||||
Path(args.out).expanduser().resolve()
|
||||
if args.out
|
||||
else manifest_path.with_suffix(manifest_path.suffix + ".sig")
|
||||
)
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(script),
|
||||
"--manifest", str(manifest_path),
|
||||
"--key", str(key_path),
|
||||
"--out", str(out_path),
|
||||
]
|
||||
log.debug("spawn %s", cmd)
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
except FileNotFoundError as exc:
|
||||
raise SubprocessError(f"cannot spawn sign-manifest.py: {exc}") from exc
|
||||
|
||||
if proc.returncode != 0:
|
||||
raise SubprocessError(
|
||||
f"sign-manifest.py exited {proc.returncode}: {proc.stderr.strip() or proc.stdout.strip()}"
|
||||
)
|
||||
if proc.stderr.strip():
|
||||
log.info("sign-manifest: %s", proc.stderr.strip())
|
||||
|
||||
if not out_path.is_file():
|
||||
raise IntegrityError(f"signature was not written to {out_path}")
|
||||
sig_bytes = out_path.read_bytes()
|
||||
if len(sig_bytes) != 64:
|
||||
raise IntegrityError(
|
||||
f"signature at {out_path} is {len(sig_bytes)} bytes, expected 64"
|
||||
)
|
||||
|
||||
return Result(
|
||||
command="release sign",
|
||||
status="signed",
|
||||
data={
|
||||
"artifacts": {
|
||||
"manifest_path": str(manifest_path),
|
||||
"signature_path": str(out_path),
|
||||
},
|
||||
"stats": {
|
||||
"signature_bytes": len(sig_bytes),
|
||||
},
|
||||
},
|
||||
)
|
||||
46
src/metin_release/commands/upload_blobs.py
Normal file
46
src/metin_release/commands/upload_blobs.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""release upload-blobs: rsync the blob tree (excluding manifest) to target."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from ..log import get_logger
|
||||
from ..result import Result
|
||||
from ..storage import rsync as rsync_backend
|
||||
from ..workspace import Context
|
||||
|
||||
|
||||
def add_parser(sub: argparse._SubParsersAction) -> argparse.ArgumentParser:
|
||||
p = sub.add_parser("upload-blobs", help="Upload release directory (except manifest) via rsync.")
|
||||
p.add_argument("--release-dir", required=True, type=Path, help="Local release output directory.")
|
||||
p.add_argument("--rsync-target", required=True, help="rsync destination (path or user@host:/path).")
|
||||
p.add_argument("--dry-run", action="store_true", help="Run rsync --dry-run.")
|
||||
p.add_argument("--yes", action="store_true", help="Skip interactive confirmation.")
|
||||
return p
|
||||
|
||||
|
||||
def run(ctx: Context, args: argparse.Namespace) -> Result:
|
||||
log = get_logger()
|
||||
release_dir = Path(args.release_dir).expanduser().resolve()
|
||||
|
||||
if not args.yes and not args.dry_run and sys.stdin.isatty():
|
||||
log.warning("about to rsync %s -> %s (use --yes to skip prompt)", release_dir, args.rsync_target)
|
||||
|
||||
rr = rsync_backend.push_blobs(release_dir, args.rsync_target, dry_run=args.dry_run)
|
||||
log.info("upload-blobs: rsync exit=%d", rr.returncode)
|
||||
|
||||
return Result(
|
||||
command="release upload-blobs",
|
||||
status="uploaded" if not args.dry_run else "dry_run",
|
||||
data={
|
||||
"remote": {
|
||||
"rsync_target": args.rsync_target,
|
||||
"dry_run": args.dry_run,
|
||||
},
|
||||
"stats": {
|
||||
"release_dir": str(release_dir),
|
||||
},
|
||||
},
|
||||
)
|
||||
126
src/metin_release/commands/verify_public.py
Normal file
126
src/metin_release/commands/verify_public.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""release verify-public: download manifest + sig and verify Ed25519 signature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
|
||||
from ..errors import NetworkError, SignatureError, ValidationError
|
||||
from ..hashing import sha256_bytes
|
||||
from ..log import get_logger
|
||||
from ..result import Result
|
||||
from ..workspace import Context
|
||||
|
||||
|
||||
def add_parser(sub: argparse._SubParsersAction) -> argparse.ArgumentParser:
|
||||
p = sub.add_parser("verify-public", help="GET manifest.json from base URL and verify signature.")
|
||||
p.add_argument("--base-url", required=True, help="Remote base URL.")
|
||||
p.add_argument(
|
||||
"--public-key",
|
||||
required=True,
|
||||
help="Ed25519 public key: hex string or path to a file containing hex.",
|
||||
)
|
||||
p.add_argument("--sample-blobs", type=int, default=0, help="GET and hash-check N random blobs.")
|
||||
p.add_argument("--timeout", type=float, default=15.0)
|
||||
return p
|
||||
|
||||
|
||||
def _load_public_key(value: str) -> Ed25519PublicKey:
|
||||
candidate = Path(value).expanduser()
|
||||
hex_text: str
|
||||
if candidate.is_file():
|
||||
hex_text = candidate.read_text(encoding="utf-8").strip()
|
||||
else:
|
||||
hex_text = value.strip()
|
||||
try:
|
||||
raw = bytes.fromhex(hex_text)
|
||||
except ValueError as exc:
|
||||
raise ValidationError(f"public key is not valid hex: {exc}") from exc
|
||||
if len(raw) != 32:
|
||||
raise ValidationError(f"public key must be 32 bytes, got {len(raw)}")
|
||||
return Ed25519PublicKey.from_public_bytes(raw)
|
||||
|
||||
|
||||
def _get(session: requests.Session, url: str, timeout: float) -> bytes:
|
||||
try:
|
||||
r = session.get(url, timeout=timeout)
|
||||
except requests.RequestException as exc:
|
||||
raise NetworkError(f"GET {url} failed: {exc}") from exc
|
||||
if r.status_code != 200:
|
||||
raise NetworkError(f"GET {url} returned {r.status_code}")
|
||||
return r.content
|
||||
|
||||
|
||||
def run(ctx: Context, args: argparse.Namespace) -> Result:
|
||||
log = get_logger()
|
||||
public_key = _load_public_key(args.public_key)
|
||||
base = args.base_url.rstrip("/")
|
||||
|
||||
with requests.Session() as session:
|
||||
manifest_bytes = _get(session, f"{base}/manifest.json", args.timeout)
|
||||
sig_bytes = _get(session, f"{base}/manifest.json.sig", args.timeout)
|
||||
|
||||
if len(sig_bytes) != 64:
|
||||
raise SignatureError(f"remote signature is {len(sig_bytes)} bytes, expected 64")
|
||||
try:
|
||||
public_key.verify(sig_bytes, manifest_bytes)
|
||||
except InvalidSignature as exc:
|
||||
raise SignatureError("manifest signature verification failed") from exc
|
||||
|
||||
manifest = json.loads(manifest_bytes.decode("utf-8"))
|
||||
version = manifest.get("version")
|
||||
created_at = manifest.get("created_at")
|
||||
|
||||
sampled_failures: list[str] = []
|
||||
sampled_count = 0
|
||||
if args.sample_blobs > 0:
|
||||
files = list(manifest.get("files", []))
|
||||
launcher = manifest.get("launcher")
|
||||
if launcher:
|
||||
files.append(launcher)
|
||||
pool = [f for f in files if f.get("sha256")]
|
||||
k = min(args.sample_blobs, len(pool))
|
||||
sample = random.sample(pool, k) if k else []
|
||||
for entry in sample:
|
||||
h = entry["sha256"]
|
||||
url = f"{base}/files/{h[:2]}/{h}"
|
||||
try:
|
||||
blob = _get(session, url, args.timeout)
|
||||
except NetworkError as exc:
|
||||
log.warning("sample blob fetch failed: %s", exc)
|
||||
sampled_failures.append(h)
|
||||
sampled_count += 1
|
||||
continue
|
||||
actual = sha256_bytes(blob)
|
||||
sampled_count += 1
|
||||
if actual != h:
|
||||
sampled_failures.append(h)
|
||||
|
||||
log.info(
|
||||
"verify-public: version=%s signature_valid=true sampled=%d failures=%d",
|
||||
version,
|
||||
sampled_count,
|
||||
len(sampled_failures),
|
||||
)
|
||||
|
||||
return Result(
|
||||
command="release verify-public",
|
||||
version=version,
|
||||
status="verified",
|
||||
data={
|
||||
"remote": {"base_url": args.base_url},
|
||||
"stats": {
|
||||
"manifest_version": version,
|
||||
"manifest_created_at": created_at,
|
||||
"signature_valid": True,
|
||||
"sampled_blob_count": sampled_count,
|
||||
"sampled_blob_failures": sampled_failures,
|
||||
},
|
||||
},
|
||||
)
|
||||
77
src/metin_release/errors.py
Normal file
77
src/metin_release/errors.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Error hierarchy. Each exception carries an exit_code and an error_code.
|
||||
|
||||
Exit code contract (from the plan):
|
||||
|
||||
0 - success
|
||||
1 - operator or validation error (bad args, missing source, wrong key mode)
|
||||
2 - remote or network error (upload / HTTP failure)
|
||||
3 - signing or integrity error (signature verify fail, hash mismatch)
|
||||
4 - ERP sync error (reserved, not used in Phase 1)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class ReleaseError(Exception):
|
||||
"""Base for all CLI errors."""
|
||||
|
||||
exit_code: int = 1
|
||||
error_code: str = "error"
|
||||
|
||||
def __init__(self, message: str, *, error_code: str | None = None):
|
||||
super().__init__(message)
|
||||
if error_code is not None:
|
||||
self.error_code = error_code
|
||||
|
||||
@property
|
||||
def message(self) -> str:
|
||||
return str(self)
|
||||
|
||||
|
||||
class ValidationError(ReleaseError):
|
||||
exit_code = 1
|
||||
error_code = "validation_error"
|
||||
|
||||
|
||||
class SourceNotFoundError(ValidationError):
|
||||
error_code = "source_not_found"
|
||||
|
||||
|
||||
class KeyPermissionError(ValidationError):
|
||||
error_code = "key_permission"
|
||||
|
||||
|
||||
class SubprocessError(ReleaseError):
|
||||
exit_code = 1
|
||||
error_code = "subprocess_failed"
|
||||
|
||||
|
||||
class RemoteError(ReleaseError):
|
||||
exit_code = 2
|
||||
error_code = "remote_error"
|
||||
|
||||
|
||||
class NetworkError(RemoteError):
|
||||
error_code = "network_error"
|
||||
|
||||
|
||||
class UploadError(RemoteError):
|
||||
error_code = "upload_failed"
|
||||
|
||||
|
||||
class IntegrityError(ReleaseError):
|
||||
exit_code = 3
|
||||
error_code = "integrity_error"
|
||||
|
||||
|
||||
class SignatureError(IntegrityError):
|
||||
error_code = "signature_invalid"
|
||||
|
||||
|
||||
class HashMismatchError(IntegrityError):
|
||||
error_code = "hash_mismatch"
|
||||
|
||||
|
||||
class ErpError(ReleaseError): # reserved for Phase 2
|
||||
exit_code = 4
|
||||
error_code = "erp_error"
|
||||
18
src/metin_release/hashing.py
Normal file
18
src/metin_release/hashing.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Streaming sha256 helper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def sha256_file(path: Path, *, chunk_size: int = 1 << 20) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(chunk_size), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def sha256_bytes(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
31
src/metin_release/log.py
Normal file
31
src/metin_release/log.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Structured stderr logger. stdout is reserved for result JSON."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
_LOGGER_NAME = "metin_release"
|
||||
|
||||
|
||||
def configure_logging(*, verbose: bool = False, quiet: bool = False) -> logging.Logger:
|
||||
logger = logging.getLogger(_LOGGER_NAME)
|
||||
logger.handlers.clear()
|
||||
|
||||
if quiet:
|
||||
level = logging.CRITICAL + 1
|
||||
elif verbose:
|
||||
level = logging.DEBUG
|
||||
else:
|
||||
level = logging.INFO
|
||||
|
||||
handler = logging.StreamHandler(stream=sys.stderr)
|
||||
handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(level)
|
||||
logger.propagate = False
|
||||
return logger
|
||||
|
||||
|
||||
def get_logger() -> logging.Logger:
|
||||
return logging.getLogger(_LOGGER_NAME)
|
||||
53
src/metin_release/result.py
Normal file
53
src/metin_release/result.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Result envelope and JSON/human output writer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class Result:
|
||||
"""Structured result for a CLI command."""
|
||||
|
||||
command: str
|
||||
ok: bool = True
|
||||
status: str = "ok"
|
||||
version: str | None = None
|
||||
data: dict[str, Any] = field(default_factory=dict)
|
||||
error_code: str | None = None
|
||||
error_message: str | None = None
|
||||
|
||||
def to_envelope(self) -> dict[str, Any]:
|
||||
env: dict[str, Any] = {
|
||||
"ok": self.ok,
|
||||
"command": self.command,
|
||||
}
|
||||
if self.version is not None:
|
||||
env["version"] = self.version
|
||||
env["status"] = self.status
|
||||
if self.ok:
|
||||
for k, v in self.data.items():
|
||||
env[k] = v
|
||||
else:
|
||||
env["error"] = {
|
||||
"code": self.error_code or "error",
|
||||
"message": self.error_message or "",
|
||||
}
|
||||
return env
|
||||
|
||||
|
||||
def write_result(result: Result, *, json_mode: bool, human_summary: str | None = None) -> None:
|
||||
"""Emit the result.
|
||||
|
||||
When json_mode is True: only JSON on stdout.
|
||||
When json_mode is False: human summary on stderr, JSON on stdout.
|
||||
"""
|
||||
envelope = result.to_envelope()
|
||||
if not json_mode and human_summary:
|
||||
print(human_summary, file=sys.stderr)
|
||||
json.dump(envelope, sys.stdout, indent=2, sort_keys=False)
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.flush()
|
||||
5
src/metin_release/storage/__init__.py
Normal file
5
src/metin_release/storage/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from . import rsync
|
||||
|
||||
__all__ = ["rsync"]
|
||||
100
src/metin_release/storage/rsync.py
Normal file
100
src/metin_release/storage/rsync.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""rsync-based remote storage backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from ..errors import UploadError, ValidationError
|
||||
from ..log import get_logger
|
||||
|
||||
|
||||
COMMON_FLAGS = [
|
||||
"-av",
|
||||
"--delay-updates",
|
||||
"--checksum",
|
||||
"--omit-dir-times",
|
||||
"--no-perms",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class RsyncResult:
|
||||
cmd: list[str]
|
||||
returncode: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
bytes_transferred: int | None = None
|
||||
|
||||
|
||||
def _ensure_rsync() -> str:
|
||||
path = shutil.which("rsync")
|
||||
if not path:
|
||||
raise ValidationError("rsync binary not found in PATH")
|
||||
return path
|
||||
|
||||
|
||||
def _normalise_source_dir(source_dir: Path) -> str:
|
||||
s = str(source_dir.resolve())
|
||||
if not s.endswith("/"):
|
||||
s += "/"
|
||||
return s
|
||||
|
||||
|
||||
def push_blobs(
|
||||
release_dir: Path,
|
||||
target: str,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
) -> RsyncResult:
|
||||
"""Upload everything in release_dir except manifest.json{,.sig}."""
|
||||
log = get_logger()
|
||||
if not release_dir.is_dir():
|
||||
raise ValidationError(f"release dir not found: {release_dir}")
|
||||
rsync = _ensure_rsync()
|
||||
cmd = [rsync, *COMMON_FLAGS]
|
||||
if dry_run:
|
||||
cmd.append("--dry-run")
|
||||
cmd += [
|
||||
"--exclude", "manifest.json",
|
||||
"--exclude", "manifest.json.sig",
|
||||
_normalise_source_dir(release_dir),
|
||||
target,
|
||||
]
|
||||
log.debug("rsync blobs: %s", cmd)
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
if proc.returncode != 0:
|
||||
raise UploadError(
|
||||
f"rsync blobs failed ({proc.returncode}): {proc.stderr.strip() or proc.stdout.strip()}"
|
||||
)
|
||||
return RsyncResult(cmd=cmd, returncode=proc.returncode, stdout=proc.stdout, stderr=proc.stderr)
|
||||
|
||||
|
||||
def push_manifest(
|
||||
release_dir: Path,
|
||||
target: str,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
) -> RsyncResult:
|
||||
"""Upload only manifest.json and manifest.json.sig."""
|
||||
log = get_logger()
|
||||
manifest = release_dir / "manifest.json"
|
||||
sig = release_dir / "manifest.json.sig"
|
||||
if not manifest.is_file() or not sig.is_file():
|
||||
raise ValidationError(
|
||||
f"manifest or signature missing in {release_dir}: need manifest.json + manifest.json.sig"
|
||||
)
|
||||
rsync = _ensure_rsync()
|
||||
cmd = [rsync, "-av", "--checksum", "--omit-dir-times", "--no-perms"]
|
||||
if dry_run:
|
||||
cmd.append("--dry-run")
|
||||
cmd += [str(manifest), str(sig), target]
|
||||
log.debug("rsync manifest: %s", cmd)
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
if proc.returncode != 0:
|
||||
raise UploadError(
|
||||
f"rsync manifest failed ({proc.returncode}): {proc.stderr.strip() or proc.stdout.strip()}"
|
||||
)
|
||||
return RsyncResult(cmd=cmd, returncode=proc.returncode, stdout=proc.stdout, stderr=proc.stderr)
|
||||
43
src/metin_release/workspace.py
Normal file
43
src/metin_release/workspace.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Workspace path helpers: source / out / staging resolution + the Context passed to commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DEFAULT_MAKE_MANIFEST = Path(
|
||||
"/home/jann/metin/repos/m2dev-client/scripts/make-manifest.py"
|
||||
)
|
||||
DEFAULT_SIGN_MANIFEST = Path(
|
||||
"/home/jann/metin/repos/m2dev-client/scripts/sign-manifest.py"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Context:
|
||||
"""Per-invocation command context. No module-level mutable state."""
|
||||
|
||||
json_mode: bool = False
|
||||
verbose: bool = False
|
||||
quiet: bool = False
|
||||
make_manifest_script: Path = field(
|
||||
default_factory=lambda: Path(
|
||||
os.environ.get("METIN_RELEASE_MAKE_MANIFEST", str(DEFAULT_MAKE_MANIFEST))
|
||||
)
|
||||
)
|
||||
sign_manifest_script: Path = field(
|
||||
default_factory=lambda: Path(
|
||||
os.environ.get("METIN_RELEASE_SIGN_MANIFEST", str(DEFAULT_SIGN_MANIFEST))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def resolve_source(source: str | os.PathLike[str]) -> Path:
|
||||
return Path(source).expanduser().resolve()
|
||||
|
||||
|
||||
def ensure_dir(path: Path) -> Path:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
9
src/metin_release_mcp/__init__.py
Normal file
9
src/metin_release_mcp/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Thin MCP server wrapping the metin-release CLI.
|
||||
|
||||
Exposes each Phase 1 ``release …`` subcommand as an MCP tool. The server
|
||||
does no business logic of its own: it maps tool input dicts to CLI flags,
|
||||
spawns ``metin-release <subcommand> --json …`` and returns the parsed JSON
|
||||
envelope as the tool result.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
5
src/metin_release_mcp/__main__.py
Normal file
5
src/metin_release_mcp/__main__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Module entry: ``python -m metin_release_mcp``."""
|
||||
|
||||
from .server import main
|
||||
|
||||
raise SystemExit(main())
|
||||
95
src/metin_release_mcp/cli_runner.py
Normal file
95
src/metin_release_mcp/cli_runner.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Spawns the metin-release CLI and parses its JSON envelope.
|
||||
|
||||
This module is the *only* place that knows how to talk to the real
|
||||
CLI. The rest of the wrapper treats its output as opaque JSON.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .errors import CliNotFoundError, CliUnparseableOutputError
|
||||
|
||||
|
||||
@dataclass
|
||||
class CliResult:
|
||||
"""Parsed outcome of one CLI invocation.
|
||||
|
||||
``envelope`` is the parsed JSON dict emitted on stdout. ``stderr``
|
||||
is the raw stderr text, surfaced for diagnostics. ``returncode`` is
|
||||
the CLI exit code.
|
||||
"""
|
||||
|
||||
envelope: dict[str, Any]
|
||||
stderr: str
|
||||
returncode: int
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.returncode == 0 and bool(self.envelope.get("ok", False))
|
||||
|
||||
|
||||
def resolve_binary(env: dict[str, str] | None = None) -> str:
|
||||
"""Locate the ``metin-release`` executable.
|
||||
|
||||
Preference order:
|
||||
|
||||
1. ``METIN_RELEASE_BINARY`` env var, if set and non-empty
|
||||
2. :func:`shutil.which` on ``metin-release``
|
||||
"""
|
||||
environ = env if env is not None else os.environ
|
||||
override = environ.get("METIN_RELEASE_BINARY", "").strip()
|
||||
if override:
|
||||
return override
|
||||
found = shutil.which("metin-release")
|
||||
if not found:
|
||||
raise CliNotFoundError(
|
||||
"metin-release binary not found; set METIN_RELEASE_BINARY or install the CLI."
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
def run_cli(
|
||||
argv_tail: list[str],
|
||||
*,
|
||||
binary: str | None = None,
|
||||
runner: Any = None,
|
||||
) -> CliResult:
|
||||
"""Spawn the CLI with ``argv_tail`` and parse its JSON stdout.
|
||||
|
||||
``runner`` is an injection seam for tests — if provided, it's called
|
||||
instead of :func:`subprocess.run` with the same arguments, and must
|
||||
return an object with ``stdout``, ``stderr``, ``returncode``.
|
||||
"""
|
||||
bin_path = binary or resolve_binary()
|
||||
cmd = [bin_path, *argv_tail]
|
||||
spawn = runner or subprocess.run
|
||||
try:
|
||||
proc = spawn(cmd, capture_output=True, text=True, check=False)
|
||||
except FileNotFoundError as exc:
|
||||
raise CliNotFoundError(f"cannot spawn metin-release: {exc}") from exc
|
||||
|
||||
stdout = (getattr(proc, "stdout", "") or "").strip()
|
||||
stderr = getattr(proc, "stderr", "") or ""
|
||||
rc = int(getattr(proc, "returncode", 1))
|
||||
|
||||
if not stdout:
|
||||
raise CliUnparseableOutputError(
|
||||
f"metin-release produced no stdout (rc={rc}); stderr={stderr.strip()!r}"
|
||||
)
|
||||
try:
|
||||
envelope = json.loads(stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise CliUnparseableOutputError(
|
||||
f"metin-release stdout was not JSON: {exc}; first 200 chars={stdout[:200]!r}"
|
||||
) from exc
|
||||
if not isinstance(envelope, dict):
|
||||
raise CliUnparseableOutputError(
|
||||
f"metin-release JSON was not an object: {type(envelope).__name__}"
|
||||
)
|
||||
return CliResult(envelope=envelope, stderr=stderr, returncode=rc)
|
||||
45
src/metin_release_mcp/errors.py
Normal file
45
src/metin_release_mcp/errors.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Error shapes surfaced by the MCP wrapper.
|
||||
|
||||
The wrapper never invents its own error codes. It either propagates the
|
||||
``{"ok": false, "error": {...}}`` envelope the CLI produced, or wraps a
|
||||
wrapper-level failure (binary missing, unparseable output, unknown tool)
|
||||
in one of the classes below.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class McpWrapperError(Exception):
|
||||
"""Base class for errors raised by the MCP wrapper itself.
|
||||
|
||||
These are *wrapper* problems (no CLI binary, junk on stdout, bad tool
|
||||
name). CLI errors are passed through untouched as JSON envelopes.
|
||||
"""
|
||||
|
||||
code: str = "mcp_wrapper_error"
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
|
||||
def to_envelope(self, *, stderr: str | None = None) -> dict:
|
||||
err: dict = {"code": self.code, "message": self.message}
|
||||
if stderr:
|
||||
err["stderr"] = stderr
|
||||
return {"ok": False, "error": err}
|
||||
|
||||
|
||||
class CliNotFoundError(McpWrapperError):
|
||||
code = "cli_not_found"
|
||||
|
||||
|
||||
class CliUnparseableOutputError(McpWrapperError):
|
||||
code = "cli_unparseable_output"
|
||||
|
||||
|
||||
class UnknownToolError(McpWrapperError):
|
||||
code = "unknown_tool"
|
||||
|
||||
|
||||
class InvalidToolInputError(McpWrapperError):
|
||||
code = "invalid_tool_input"
|
||||
172
src/metin_release_mcp/server.py
Normal file
172
src/metin_release_mcp/server.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""MCP stdio server entry point.
|
||||
|
||||
Registers each :data:`~metin_release_mcp.tool_defs.TOOL_SPECS` entry as
|
||||
an MCP tool. On invocation the handler:
|
||||
|
||||
1. Validates input via the tool's JSON schema (enforced by the SDK) and
|
||||
by :func:`~metin_release_mcp.tool_defs.build_cli_args`.
|
||||
2. Shells out to ``metin-release <subcommand> --json …`` via
|
||||
:func:`~metin_release_mcp.cli_runner.run_cli`.
|
||||
3. Returns the parsed JSON envelope as the MCP tool result.
|
||||
|
||||
The server contains **no release business logic**. If the CLI returns a
|
||||
non-zero exit or an ``ok=false`` envelope, the wrapper passes that
|
||||
envelope back to the caller verbatim, tagging it with the CLI stderr as
|
||||
diagnostic text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from . import __version__
|
||||
from .errors import (
|
||||
CliNotFoundError,
|
||||
CliUnparseableOutputError,
|
||||
InvalidToolInputError,
|
||||
McpWrapperError,
|
||||
UnknownToolError,
|
||||
)
|
||||
from .cli_runner import run_cli
|
||||
from .tool_defs import TOOL_SPECS, TOOLS_BY_NAME, build_cli_args, json_schema
|
||||
|
||||
|
||||
def _build_mcp_server(): # pragma: no cover - imported lazily for tests
|
||||
from mcp.server import Server
|
||||
from mcp.types import TextContent, Tool
|
||||
|
||||
server = Server("metin-release-mcp", version=__version__)
|
||||
|
||||
@server.list_tools()
|
||||
async def _list_tools() -> list[Tool]:
|
||||
return [
|
||||
Tool(
|
||||
name=spec.name,
|
||||
description=spec.description,
|
||||
inputSchema=json_schema(spec),
|
||||
)
|
||||
for spec in TOOL_SPECS
|
||||
]
|
||||
|
||||
@server.call_tool()
|
||||
async def _call_tool(name: str, arguments: dict[str, Any] | None):
|
||||
envelope, stderr = dispatch(name, arguments or {})
|
||||
text = json.dumps(envelope, indent=2, sort_keys=False)
|
||||
content = [TextContent(type="text", text=text)]
|
||||
if stderr.strip():
|
||||
content.append(
|
||||
TextContent(type="text", text=f"metin-release stderr:\n{stderr}")
|
||||
)
|
||||
return content, envelope
|
||||
|
||||
return server
|
||||
|
||||
|
||||
def dispatch(tool_name: str, payload: dict[str, Any]) -> tuple[dict[str, Any], str]:
|
||||
"""Dispatch a tool call synchronously.
|
||||
|
||||
Returns ``(envelope, stderr)``. On wrapper errors the envelope is
|
||||
shaped like the CLI's own ``{"ok": false, "error": {...}}`` error
|
||||
envelope so callers can treat both error sources the same way.
|
||||
"""
|
||||
spec = TOOLS_BY_NAME.get(tool_name)
|
||||
if spec is None:
|
||||
raise UnknownToolError(
|
||||
f"unknown tool: {tool_name!r} (known: {sorted(TOOLS_BY_NAME)})"
|
||||
)
|
||||
try:
|
||||
argv_tail = build_cli_args(spec, payload)
|
||||
except InvalidToolInputError as exc:
|
||||
return exc.to_envelope(), ""
|
||||
|
||||
try:
|
||||
result = run_cli(argv_tail)
|
||||
except (CliNotFoundError, CliUnparseableOutputError) as exc:
|
||||
return exc.to_envelope(stderr=None), ""
|
||||
|
||||
return result.envelope, result.stderr
|
||||
|
||||
|
||||
def _print_help() -> None:
|
||||
lines = [
|
||||
f"metin-release-mcp {__version__}",
|
||||
"",
|
||||
"Thin MCP server wrapping the metin-release CLI over stdio.",
|
||||
"",
|
||||
"Usage: metin-release-mcp [--help | --version | --list-tools]",
|
||||
"",
|
||||
"With no arguments, runs an MCP server on stdio and registers these tools:",
|
||||
"",
|
||||
]
|
||||
for spec in TOOL_SPECS:
|
||||
lines.append(f" {spec.name:<26} {spec.description}")
|
||||
lines.append("")
|
||||
lines.append("Environment:")
|
||||
lines.append(
|
||||
" METIN_RELEASE_BINARY Path to metin-release CLI (else resolved via PATH)"
|
||||
)
|
||||
print("\n".join(lines))
|
||||
|
||||
|
||||
def _print_tools() -> None:
|
||||
out = [
|
||||
{"name": s.name, "description": s.description, "inputSchema": json_schema(s)}
|
||||
for s in TOOL_SPECS
|
||||
]
|
||||
json.dump(out, sys.stdout, indent=2)
|
||||
sys.stdout.write("\n")
|
||||
|
||||
|
||||
async def _run_stdio() -> None: # pragma: no cover - exercised only end-to-end
|
||||
from mcp.server.stdio import stdio_server
|
||||
|
||||
server = _build_mcp_server()
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await server.run(
|
||||
read_stream,
|
||||
write_stream,
|
||||
server.create_initialization_options(),
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="metin-release-mcp",
|
||||
description="Thin MCP server wrapping the metin-release CLI.",
|
||||
add_help=False,
|
||||
)
|
||||
parser.add_argument("-h", "--help", action="store_true")
|
||||
parser.add_argument("--version", action="store_true")
|
||||
parser.add_argument(
|
||||
"--list-tools",
|
||||
action="store_true",
|
||||
help="Print registered tool schemas as JSON and exit.",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.help:
|
||||
_print_help()
|
||||
return 0
|
||||
if args.version:
|
||||
print(f"metin-release-mcp {__version__}")
|
||||
return 0
|
||||
if args.list_tools:
|
||||
_print_tools()
|
||||
return 0
|
||||
|
||||
try:
|
||||
asyncio.run(_run_stdio())
|
||||
except McpWrapperError as exc:
|
||||
print(json.dumps(exc.to_envelope()), file=sys.stderr)
|
||||
return 1
|
||||
except KeyboardInterrupt: # pragma: no cover
|
||||
return 130
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(main())
|
||||
232
src/metin_release_mcp/tool_defs.py
Normal file
232
src/metin_release_mcp/tool_defs.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""Declarative tool specs for the Phase 3 MCP wrapper.
|
||||
|
||||
Each tool corresponds 1:1 to a ``metin-release release …`` subcommand. A
|
||||
single :class:`ToolSpec` list drives three things:
|
||||
|
||||
1. The MCP ``list_tools`` response (via :func:`json_schema`)
|
||||
2. Input validation for ``call_tool``
|
||||
3. The CLI flag list emitted by :mod:`cli_runner`
|
||||
|
||||
Keeping all three in one place is what lets us guarantee the schema
|
||||
matches the real argparse signature without duplicating it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
|
||||
FieldType = Literal["string", "integer", "number", "boolean", "path", "array"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FieldSpec:
|
||||
"""One tool input field.
|
||||
|
||||
``name`` is the MCP-facing key (snake_case). ``flag`` is the argparse
|
||||
flag name; if omitted it's derived from ``name`` by replacing ``_``
|
||||
with ``-`` and prefixing ``--``. ``kind`` maps to JSON Schema types:
|
||||
``path`` is a string that the CLI will read as a filesystem path,
|
||||
``boolean`` is a store_true flag (passed with no value when truthy
|
||||
and omitted when falsy).
|
||||
"""
|
||||
|
||||
name: str
|
||||
kind: FieldType
|
||||
required: bool = False
|
||||
description: str = ""
|
||||
flag: str | None = None
|
||||
default: Any = None
|
||||
|
||||
@property
|
||||
def cli_flag(self) -> str:
|
||||
return self.flag or f"--{self.name.replace('_', '-')}"
|
||||
|
||||
@property
|
||||
def json_type(self) -> str:
|
||||
if self.kind == "path":
|
||||
return "string"
|
||||
if self.kind == "array":
|
||||
return "array"
|
||||
return self.kind
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolSpec:
|
||||
name: str
|
||||
subcommand: tuple[str, ...]
|
||||
description: str
|
||||
fields: tuple[FieldSpec, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool catalogue — must stay 1:1 with metin_release.commands.*.add_parser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_RELEASE = ("release",)
|
||||
|
||||
|
||||
TOOL_SPECS: tuple[ToolSpec, ...] = (
|
||||
ToolSpec(
|
||||
name="release_inspect",
|
||||
subcommand=_RELEASE + ("inspect",),
|
||||
description="Scan a client source root and report file/byte counts plus launcher/main-exe presence.",
|
||||
fields=(
|
||||
FieldSpec("source", "path", required=True, description="Client root directory."),
|
||||
),
|
||||
),
|
||||
ToolSpec(
|
||||
name="release_build_manifest",
|
||||
subcommand=_RELEASE + ("build-manifest",),
|
||||
description="Build a signed-ready manifest.json for a source tree.",
|
||||
fields=(
|
||||
FieldSpec("source", "path", required=True, description="Client source root."),
|
||||
FieldSpec("version", "string", required=True, description="Release version, e.g. 2026.04.14-1."),
|
||||
FieldSpec("out", "path", required=True, description="Output manifest.json path."),
|
||||
FieldSpec("previous", "string", description="Previous release version, if any."),
|
||||
FieldSpec("notes", "path", description="Path to a release-notes file."),
|
||||
FieldSpec("launcher", "string", description="Launcher filename (default Metin2Launcher.exe)."),
|
||||
FieldSpec("created_at", "string", description="Override created_at for reproducible test runs."),
|
||||
),
|
||||
),
|
||||
ToolSpec(
|
||||
name="release_sign",
|
||||
subcommand=_RELEASE + ("sign",),
|
||||
description="Sign a manifest.json with an Ed25519 private key (mode-600 enforced).",
|
||||
fields=(
|
||||
FieldSpec("manifest", "path", required=True, description="Path to manifest.json."),
|
||||
FieldSpec("key", "path", required=True, description="Absolute path to raw 32-byte private key."),
|
||||
FieldSpec("out", "path", description="Signature output path (default: <manifest>.sig)."),
|
||||
),
|
||||
),
|
||||
ToolSpec(
|
||||
name="release_diff_remote",
|
||||
subcommand=_RELEASE + ("diff-remote",),
|
||||
description="HEAD every manifest blob hash against a base URL and report the missing set.",
|
||||
fields=(
|
||||
FieldSpec("manifest", "path", required=True, description="Path to manifest.json."),
|
||||
FieldSpec("base_url", "string", required=True, description="Remote base URL."),
|
||||
FieldSpec("timeout", "number", description="Per-request timeout in seconds (default 10)."),
|
||||
),
|
||||
),
|
||||
ToolSpec(
|
||||
name="release_upload_blobs",
|
||||
subcommand=_RELEASE + ("upload-blobs",),
|
||||
description="Rsync a release directory (excluding manifest) to the target.",
|
||||
fields=(
|
||||
FieldSpec("release_dir", "path", required=True, description="Local release output directory."),
|
||||
FieldSpec("rsync_target", "string", required=True, description="rsync destination."),
|
||||
FieldSpec("dry_run", "boolean", description="Run rsync --dry-run."),
|
||||
FieldSpec("yes", "boolean", description="Skip interactive confirmation."),
|
||||
),
|
||||
),
|
||||
ToolSpec(
|
||||
name="release_promote",
|
||||
subcommand=_RELEASE + ("promote",),
|
||||
description="Promote a staged release by pushing manifest.json + manifest.json.sig.",
|
||||
fields=(
|
||||
FieldSpec("release_dir", "path", required=True, description="Local release directory."),
|
||||
FieldSpec("rsync_target", "string", required=True, description="rsync destination top-level."),
|
||||
FieldSpec("yes", "boolean", description="Skip interactive confirmation."),
|
||||
FieldSpec("dry_run", "boolean", description="Run rsync --dry-run."),
|
||||
),
|
||||
),
|
||||
ToolSpec(
|
||||
name="release_verify_public",
|
||||
subcommand=_RELEASE + ("verify-public",),
|
||||
description="Fetch manifest.json + signature from a base URL and Ed25519-verify.",
|
||||
fields=(
|
||||
FieldSpec("base_url", "string", required=True, description="Remote base URL."),
|
||||
FieldSpec("public_key", "string", required=True, description="Ed25519 public key: hex or path."),
|
||||
FieldSpec("sample_blobs", "integer", description="GET and hash-check N random blobs."),
|
||||
FieldSpec("timeout", "number", description="Per-request timeout in seconds (default 15)."),
|
||||
),
|
||||
),
|
||||
ToolSpec(
|
||||
name="release_publish",
|
||||
subcommand=_RELEASE + ("publish",),
|
||||
description="End-to-end release: build-manifest -> sign -> upload-blobs -> promote -> verify-public.",
|
||||
fields=(
|
||||
FieldSpec("source", "path", required=True, description="Client source root."),
|
||||
FieldSpec("version", "string", required=True, description="Release version."),
|
||||
FieldSpec("out", "path", required=True, description="Release output directory."),
|
||||
FieldSpec("key", "path", required=True, description="Signing key path (mode 600)."),
|
||||
FieldSpec("rsync_target", "string", required=True, description="rsync target for blobs + manifest."),
|
||||
FieldSpec("base_url", "string", required=True, description="Public base URL for verification."),
|
||||
FieldSpec("public_key", "string", required=True, description="Public key (hex or file)."),
|
||||
FieldSpec("previous", "string", description="Previous release version."),
|
||||
FieldSpec("notes", "path", description="Release notes file."),
|
||||
FieldSpec("launcher", "string", description="Launcher filename."),
|
||||
FieldSpec("created_at", "string", description="Override manifest created_at."),
|
||||
FieldSpec("sample_blobs", "integer", description="verify-public sample count."),
|
||||
FieldSpec("yes", "boolean", description="Skip interactive confirmation."),
|
||||
FieldSpec("force", "boolean", description="Allow non-empty output directory."),
|
||||
FieldSpec("dry_run_upload", "boolean", description="rsync --dry-run for upload + promote."),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
TOOLS_BY_NAME: dict[str, ToolSpec] = {t.name: t for t in TOOL_SPECS}
|
||||
|
||||
|
||||
def json_schema(spec: ToolSpec) -> dict[str, Any]:
|
||||
"""Build a JSON Schema for one tool's input object."""
|
||||
props: dict[str, Any] = {}
|
||||
required: list[str] = []
|
||||
for f in spec.fields:
|
||||
prop: dict[str, Any] = {"type": f.json_type}
|
||||
if f.description:
|
||||
prop["description"] = f.description
|
||||
props[f.name] = prop
|
||||
if f.required:
|
||||
required.append(f.name)
|
||||
schema: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": props,
|
||||
"additionalProperties": False,
|
||||
}
|
||||
if required:
|
||||
schema["required"] = required
|
||||
return schema
|
||||
|
||||
|
||||
def build_cli_args(spec: ToolSpec, payload: dict[str, Any]) -> list[str]:
|
||||
"""Translate a tool input dict into a ``metin-release`` argv tail.
|
||||
|
||||
Output is ``[*subcommand, "--json", *flags]``. Unknown keys raise
|
||||
:class:`~metin_release_mcp.errors.InvalidToolInputError`. Required
|
||||
fields that are missing do the same.
|
||||
"""
|
||||
from .errors import InvalidToolInputError
|
||||
|
||||
known = {f.name for f in spec.fields}
|
||||
unknown = set(payload) - known
|
||||
if unknown:
|
||||
raise InvalidToolInputError(
|
||||
f"tool {spec.name} got unknown fields: {sorted(unknown)}"
|
||||
)
|
||||
|
||||
argv: list[str] = [*spec.subcommand, "--json"]
|
||||
for f in spec.fields:
|
||||
if f.name not in payload or payload[f.name] is None:
|
||||
if f.required:
|
||||
raise InvalidToolInputError(
|
||||
f"tool {spec.name} missing required field: {f.name}"
|
||||
)
|
||||
continue
|
||||
value = payload[f.name]
|
||||
if f.kind == "boolean":
|
||||
if bool(value):
|
||||
argv.append(f.cli_flag)
|
||||
continue
|
||||
if f.kind == "array":
|
||||
if not isinstance(value, list):
|
||||
raise InvalidToolInputError(
|
||||
f"field {f.name} must be an array"
|
||||
)
|
||||
for item in value:
|
||||
argv.extend([f.cli_flag, str(item)])
|
||||
continue
|
||||
argv.extend([f.cli_flag, str(value)])
|
||||
return argv
|
||||
66
tests/conftest.py
Normal file
66
tests/conftest.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure editable install's src path is on sys.path when running from checkout.
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SRC = ROOT / "src"
|
||||
if str(SRC) not in sys.path:
|
||||
sys.path.insert(0, str(SRC))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tiny_client(tmp_path: Path) -> Path:
|
||||
"""Copy the checked-in tiny_client fixture into a tmp dir and return it."""
|
||||
src = Path(__file__).parent / "fixtures" / "tiny_client"
|
||||
dst = tmp_path / "client"
|
||||
shutil.copytree(src, dst)
|
||||
return dst
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_manifest_script() -> Path:
|
||||
return Path("/home/jann/metin/repos/m2dev-client/scripts/make-manifest.py")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sign_manifest_script() -> Path:
|
||||
return Path("/home/jann/metin/repos/m2dev-client/scripts/sign-manifest.py")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_env(monkeypatch):
|
||||
# Don't let ambient env vars bleed into tests.
|
||||
for key in ("METIN_RELEASE_MAKE_MANIFEST", "METIN_RELEASE_SIGN_MANIFEST"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_keypair(tmp_path: Path) -> tuple[Path, str]:
|
||||
"""Generate an ephemeral Ed25519 keypair, write the private key mode 600.
|
||||
|
||||
Returns (private_key_path, public_key_hex).
|
||||
"""
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
priv = Ed25519PrivateKey.generate()
|
||||
raw = priv.private_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PrivateFormat.Raw,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
key_path = tmp_path / "key"
|
||||
key_path.write_bytes(raw)
|
||||
os.chmod(key_path, 0o600)
|
||||
pub_hex = priv.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
).hex()
|
||||
return key_path, pub_hex
|
||||
29
tests/fixtures/http_server.py
vendored
Normal file
29
tests/fixtures/http_server.py
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Minimal threaded HTTP server for diff-remote / verify-public tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import threading
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
|
||||
class _Handler(SimpleHTTPRequestHandler):
|
||||
def log_message(self, format, *args): # noqa: A002
|
||||
pass # silence
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def serve_dir(root: Path) -> Iterator[str]:
|
||||
"""Serve `root` on 127.0.0.1:<free port>, yield the base URL."""
|
||||
handler = lambda *a, **kw: _Handler(*a, directory=str(root), **kw) # noqa: E731
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{server.server_port}"
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=2)
|
||||
1
tests/fixtures/tiny_client/Metin2.exe
vendored
Normal file
1
tests/fixtures/tiny_client/Metin2.exe
vendored
Normal file
@@ -0,0 +1 @@
|
||||
fake main executable content
|
||||
1
tests/fixtures/tiny_client/Metin2Launcher.exe
vendored
Normal file
1
tests/fixtures/tiny_client/Metin2Launcher.exe
vendored
Normal file
@@ -0,0 +1 @@
|
||||
fake launcher content
|
||||
1
tests/fixtures/tiny_client/readme.txt
vendored
Normal file
1
tests/fixtures/tiny_client/readme.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
tiny client fixture for tests
|
||||
1
tests/fixtures/tiny_client/subdir/asset.bin
vendored
Normal file
1
tests/fixtures/tiny_client/subdir/asset.bin
vendored
Normal file
@@ -0,0 +1 @@
|
||||
asset payload bytes
|
||||
1
tests/fixtures/tiny_client/subdir/other.dat
vendored
Normal file
1
tests/fixtures/tiny_client/subdir/other.dat
vendored
Normal file
@@ -0,0 +1 @@
|
||||
second asset payload
|
||||
0
tests/mcp/__init__.py
Normal file
0
tests/mcp/__init__.py
Normal file
32
tests/mcp/conftest.py
Normal file
32
tests/mcp/conftest.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Fixtures for the MCP wrapper test suite."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeProc:
|
||||
stdout: str
|
||||
stderr: str = ""
|
||||
returncode: int = 0
|
||||
|
||||
|
||||
def make_runner(envelope: dict | None = None, *, stdout: str | None = None, stderr: str = "", returncode: int = 0):
|
||||
"""Return a callable compatible with :func:`subprocess.run`.
|
||||
|
||||
Records the most recent ``cmd`` on ``runner.calls``.
|
||||
"""
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def runner(cmd, capture_output=True, text=True, check=False): # noqa: ARG001
|
||||
calls.append(list(cmd))
|
||||
if stdout is not None:
|
||||
s = stdout
|
||||
else:
|
||||
s = json.dumps(envelope if envelope is not None else {"ok": True, "command": "x", "status": "ok"})
|
||||
return FakeProc(stdout=s, stderr=stderr, returncode=returncode)
|
||||
|
||||
runner.calls = calls # type: ignore[attr-defined]
|
||||
return runner
|
||||
86
tests/mcp/test_cli_runner.py
Normal file
86
tests/mcp/test_cli_runner.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Tests for :mod:`metin_release_mcp.cli_runner`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from metin_release_mcp import cli_runner
|
||||
from metin_release_mcp.errors import CliNotFoundError, CliUnparseableOutputError
|
||||
|
||||
from .conftest import make_runner
|
||||
|
||||
|
||||
def test_run_cli_success_parses_envelope():
|
||||
runner = make_runner({"ok": True, "command": "release inspect", "status": "inspected", "stats": {"file_count": 3}})
|
||||
result = cli_runner.run_cli(
|
||||
["release", "inspect", "--json", "--source", "/tmp/x"],
|
||||
binary="/fake/metin-release",
|
||||
runner=runner,
|
||||
)
|
||||
assert result.ok is True
|
||||
assert result.envelope["status"] == "inspected"
|
||||
assert result.envelope["stats"]["file_count"] == 3
|
||||
assert result.returncode == 0
|
||||
assert runner.calls[0][0] == "/fake/metin-release"
|
||||
assert "--json" in runner.calls[0]
|
||||
|
||||
|
||||
def test_run_cli_error_envelope_passed_through():
|
||||
err = {
|
||||
"ok": False,
|
||||
"command": "release sign",
|
||||
"status": "failed",
|
||||
"error": {"code": "key_permission", "message": "key must be mode 600"},
|
||||
}
|
||||
runner = make_runner(err, stderr="error: key_permission\n", returncode=3)
|
||||
result = cli_runner.run_cli(["release", "sign"], binary="/fake/mr", runner=runner)
|
||||
assert result.ok is False
|
||||
assert result.returncode == 3
|
||||
assert result.envelope["error"]["code"] == "key_permission"
|
||||
assert "key_permission" in result.stderr
|
||||
|
||||
|
||||
def test_run_cli_unparseable_output_raises():
|
||||
runner = make_runner(stdout="not-json-at-all", returncode=0)
|
||||
with pytest.raises(CliUnparseableOutputError):
|
||||
cli_runner.run_cli(["release", "inspect"], binary="/fake/mr", runner=runner)
|
||||
|
||||
|
||||
def test_run_cli_empty_stdout_raises():
|
||||
runner = make_runner(stdout=" \n", stderr="boom", returncode=2)
|
||||
with pytest.raises(CliUnparseableOutputError):
|
||||
cli_runner.run_cli(["release", "inspect"], binary="/fake/mr", runner=runner)
|
||||
|
||||
|
||||
def test_run_cli_non_object_json_raises():
|
||||
runner = make_runner(stdout=json.dumps([1, 2, 3]))
|
||||
with pytest.raises(CliUnparseableOutputError):
|
||||
cli_runner.run_cli(["release", "inspect"], binary="/fake/mr", runner=runner)
|
||||
|
||||
|
||||
def test_run_cli_file_not_found_raises():
|
||||
def boom(*args, **kwargs):
|
||||
raise FileNotFoundError("no such file")
|
||||
|
||||
with pytest.raises(CliNotFoundError):
|
||||
cli_runner.run_cli(["release", "inspect"], binary="/does/not/exist", runner=boom)
|
||||
|
||||
|
||||
def test_resolve_binary_env_override(monkeypatch):
|
||||
monkeypatch.setenv("METIN_RELEASE_BINARY", "/custom/path/metin-release")
|
||||
assert cli_runner.resolve_binary() == "/custom/path/metin-release"
|
||||
|
||||
|
||||
def test_resolve_binary_which_fallback(monkeypatch):
|
||||
monkeypatch.delenv("METIN_RELEASE_BINARY", raising=False)
|
||||
monkeypatch.setattr(cli_runner.shutil, "which", lambda name: "/usr/bin/metin-release")
|
||||
assert cli_runner.resolve_binary() == "/usr/bin/metin-release"
|
||||
|
||||
|
||||
def test_resolve_binary_missing_raises(monkeypatch):
|
||||
monkeypatch.delenv("METIN_RELEASE_BINARY", raising=False)
|
||||
monkeypatch.setattr(cli_runner.shutil, "which", lambda name: None)
|
||||
with pytest.raises(CliNotFoundError):
|
||||
cli_runner.resolve_binary()
|
||||
211
tests/mcp/test_tool_dispatch.py
Normal file
211
tests/mcp/test_tool_dispatch.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""Dispatch tests — input dict to CLI argv translation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from metin_release_mcp import cli_runner, server
|
||||
from metin_release_mcp.tool_defs import TOOLS_BY_NAME, build_cli_args
|
||||
|
||||
from .conftest import make_runner
|
||||
|
||||
|
||||
def test_inspect_maps_source_to_flag():
|
||||
spec = TOOLS_BY_NAME["release_inspect"]
|
||||
argv = build_cli_args(spec, {"source": "/tmp/client"})
|
||||
assert argv == ["release", "inspect", "--json", "--source", "/tmp/client"]
|
||||
|
||||
|
||||
def test_build_manifest_full_flags():
|
||||
spec = TOOLS_BY_NAME["release_build_manifest"]
|
||||
argv = build_cli_args(
|
||||
spec,
|
||||
{
|
||||
"source": "/src",
|
||||
"version": "2026.04.14-1",
|
||||
"out": "/out/manifest.json",
|
||||
"previous": "2026.04.13-1",
|
||||
"notes": "/notes.md",
|
||||
"launcher": "Metin2Launcher.exe",
|
||||
"created_at": "2026-04-14T00:00:00Z",
|
||||
},
|
||||
)
|
||||
# Required args present in order declared
|
||||
assert argv[0:3] == ["release", "build-manifest", "--json"]
|
||||
assert "--source" in argv and argv[argv.index("--source") + 1] == "/src"
|
||||
assert "--version" in argv and argv[argv.index("--version") + 1] == "2026.04.14-1"
|
||||
assert "--out" in argv and argv[argv.index("--out") + 1] == "/out/manifest.json"
|
||||
assert "--previous" in argv and argv[argv.index("--previous") + 1] == "2026.04.13-1"
|
||||
assert "--created-at" in argv
|
||||
assert argv[argv.index("--created-at") + 1] == "2026-04-14T00:00:00Z"
|
||||
|
||||
|
||||
def test_build_manifest_omits_missing_optionals():
|
||||
spec = TOOLS_BY_NAME["release_build_manifest"]
|
||||
argv = build_cli_args(
|
||||
spec,
|
||||
{"source": "/src", "version": "v1", "out": "/m.json"},
|
||||
)
|
||||
assert "--previous" not in argv
|
||||
assert "--notes" not in argv
|
||||
assert "--launcher" not in argv
|
||||
assert "--created-at" not in argv
|
||||
|
||||
|
||||
def test_none_valued_optional_is_omitted():
|
||||
spec = TOOLS_BY_NAME["release_build_manifest"]
|
||||
argv = build_cli_args(
|
||||
spec,
|
||||
{"source": "/s", "version": "v", "out": "/m", "previous": None, "notes": None},
|
||||
)
|
||||
assert "--previous" not in argv
|
||||
assert "--notes" not in argv
|
||||
|
||||
|
||||
def test_boolean_flag_true_adds_flag_without_value():
|
||||
spec = TOOLS_BY_NAME["release_upload_blobs"]
|
||||
argv = build_cli_args(
|
||||
spec,
|
||||
{
|
||||
"release_dir": "/rel",
|
||||
"rsync_target": "user@host:/srv/updates",
|
||||
"dry_run": True,
|
||||
"yes": True,
|
||||
},
|
||||
)
|
||||
assert "--dry-run" in argv
|
||||
assert "--yes" in argv
|
||||
# No value should follow --dry-run
|
||||
i = argv.index("--dry-run")
|
||||
# next element either end of list or another flag
|
||||
assert i == len(argv) - 1 or argv[i + 1].startswith("--")
|
||||
|
||||
|
||||
def test_boolean_flag_false_omits_flag():
|
||||
spec = TOOLS_BY_NAME["release_upload_blobs"]
|
||||
argv = build_cli_args(
|
||||
spec,
|
||||
{"release_dir": "/rel", "rsync_target": "tgt", "dry_run": False, "yes": False},
|
||||
)
|
||||
assert "--dry-run" not in argv
|
||||
assert "--yes" not in argv
|
||||
|
||||
|
||||
def test_path_with_spaces_passes_through_as_single_argv_element():
|
||||
spec = TOOLS_BY_NAME["release_inspect"]
|
||||
argv = build_cli_args(spec, {"source": "/tmp/has spaces/client root"})
|
||||
# argv is a list — no shell involved, so spaces stay in one element
|
||||
assert "/tmp/has spaces/client root" in argv
|
||||
assert argv.count("--source") == 1
|
||||
|
||||
|
||||
def test_diff_remote_numeric_timeout_serialised_as_string():
|
||||
spec = TOOLS_BY_NAME["release_diff_remote"]
|
||||
argv = build_cli_args(
|
||||
spec,
|
||||
{"manifest": "/m.json", "base_url": "https://x/", "timeout": 7.5},
|
||||
)
|
||||
assert argv[argv.index("--timeout") + 1] == "7.5"
|
||||
|
||||
|
||||
def test_publish_mixes_booleans_and_strings():
|
||||
spec = TOOLS_BY_NAME["release_publish"]
|
||||
argv = build_cli_args(
|
||||
spec,
|
||||
{
|
||||
"source": "/src",
|
||||
"version": "v1",
|
||||
"out": "/out",
|
||||
"key": "/k",
|
||||
"rsync_target": "t",
|
||||
"base_url": "https://x/",
|
||||
"public_key": "deadbeef",
|
||||
"force": True,
|
||||
"dry_run_upload": True,
|
||||
"yes": False,
|
||||
},
|
||||
)
|
||||
assert "--force" in argv
|
||||
assert "--dry-run-upload" in argv
|
||||
assert "--yes" not in argv
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# server.dispatch integration against a stubbed CLI runner
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stub_runner(monkeypatch):
|
||||
def install(envelope=None, *, stdout=None, stderr="", returncode=0):
|
||||
runner = make_runner(envelope, stdout=stdout, stderr=stderr, returncode=returncode)
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"run_cli",
|
||||
lambda argv_tail: cli_runner.run_cli(
|
||||
argv_tail, binary="/fake/metin-release", runner=runner
|
||||
),
|
||||
)
|
||||
return runner
|
||||
|
||||
return install
|
||||
|
||||
|
||||
def test_dispatch_success_returns_envelope(stub_runner):
|
||||
runner = stub_runner(
|
||||
{"ok": True, "command": "release inspect", "status": "inspected", "stats": {"file_count": 42}}
|
||||
)
|
||||
envelope, stderr = server.dispatch("release_inspect", {"source": "/tmp/x"})
|
||||
assert envelope["ok"] is True
|
||||
assert envelope["stats"]["file_count"] == 42
|
||||
# the runner saw --json
|
||||
assert "--json" in runner.calls[0]
|
||||
assert "--source" in runner.calls[0]
|
||||
assert "/tmp/x" in runner.calls[0]
|
||||
|
||||
|
||||
def test_dispatch_cli_error_envelope_passes_through(stub_runner):
|
||||
err = {
|
||||
"ok": False,
|
||||
"command": "release sign",
|
||||
"status": "failed",
|
||||
"error": {"code": "key_permission", "message": "mode 644"},
|
||||
}
|
||||
stub_runner(err, stderr="error\n", returncode=3)
|
||||
envelope, stderr = server.dispatch(
|
||||
"release_sign", {"manifest": "/m.json", "key": "/k"}
|
||||
)
|
||||
assert envelope == err
|
||||
assert "error" in stderr
|
||||
|
||||
|
||||
def test_dispatch_cli_binary_missing_returns_wrapper_error(monkeypatch):
|
||||
def boom(argv_tail):
|
||||
from metin_release_mcp.errors import CliNotFoundError
|
||||
|
||||
raise CliNotFoundError("metin-release binary not found")
|
||||
|
||||
monkeypatch.setattr(server, "run_cli", boom)
|
||||
envelope, stderr = server.dispatch("release_inspect", {"source": "/x"})
|
||||
assert envelope["ok"] is False
|
||||
assert envelope["error"]["code"] == "cli_not_found"
|
||||
|
||||
|
||||
def test_dispatch_invalid_input_returns_wrapper_error():
|
||||
envelope, _ = server.dispatch("release_inspect", {})
|
||||
assert envelope["ok"] is False
|
||||
assert envelope["error"]["code"] == "invalid_tool_input"
|
||||
|
||||
|
||||
def test_dispatch_unparseable_output_returns_wrapper_error(monkeypatch):
|
||||
def boom(argv_tail):
|
||||
from metin_release_mcp.errors import CliUnparseableOutputError
|
||||
|
||||
raise CliUnparseableOutputError("not json")
|
||||
|
||||
monkeypatch.setattr(server, "run_cli", boom)
|
||||
envelope, _ = server.dispatch("release_inspect", {"source": "/x"})
|
||||
assert envelope["ok"] is False
|
||||
assert envelope["error"]["code"] == "cli_unparseable_output"
|
||||
140
tests/mcp/test_tool_schema.py
Normal file
140
tests/mcp/test_tool_schema.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Schema tests — each MCP tool must mirror its CLI subcommand exactly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
import pytest
|
||||
|
||||
from metin_release.commands import (
|
||||
build_manifest,
|
||||
diff_remote,
|
||||
inspect,
|
||||
promote,
|
||||
publish,
|
||||
sign,
|
||||
upload_blobs,
|
||||
verify_public,
|
||||
)
|
||||
from metin_release_mcp.errors import UnknownToolError
|
||||
from metin_release_mcp.server import dispatch
|
||||
from metin_release_mcp.tool_defs import (
|
||||
TOOL_SPECS,
|
||||
TOOLS_BY_NAME,
|
||||
build_cli_args,
|
||||
json_schema,
|
||||
)
|
||||
|
||||
|
||||
EXPECTED_TOOL_NAMES = {
|
||||
"release_inspect",
|
||||
"release_build_manifest",
|
||||
"release_sign",
|
||||
"release_diff_remote",
|
||||
"release_upload_blobs",
|
||||
"release_promote",
|
||||
"release_verify_public",
|
||||
"release_publish",
|
||||
}
|
||||
|
||||
|
||||
def test_tool_catalogue_is_exactly_the_phase_one_set():
|
||||
assert {t.name for t in TOOL_SPECS} == EXPECTED_TOOL_NAMES
|
||||
assert len(TOOL_SPECS) == 8
|
||||
|
||||
|
||||
def test_every_tool_has_release_subcommand_and_description():
|
||||
for spec in TOOL_SPECS:
|
||||
assert spec.subcommand[0] == "release"
|
||||
assert spec.description.strip()
|
||||
schema = json_schema(spec)
|
||||
assert schema["type"] == "object"
|
||||
assert schema["additionalProperties"] is False
|
||||
|
||||
|
||||
def _argparse_flags_for(add_parser_fn) -> dict[str, dict]:
|
||||
"""Introspect a command's argparse signature.
|
||||
|
||||
Returns a mapping from flag name (e.g. ``--source``) to a dict with
|
||||
``required`` and ``kind`` ("boolean" for store_true, else "value").
|
||||
"""
|
||||
parser = argparse.ArgumentParser()
|
||||
sub = parser.add_subparsers()
|
||||
sp = add_parser_fn(sub)
|
||||
out: dict[str, dict] = {}
|
||||
for action in sp._actions:
|
||||
if not action.option_strings:
|
||||
continue
|
||||
if action.dest in ("help",):
|
||||
continue
|
||||
flag = action.option_strings[0]
|
||||
is_bool = isinstance(action, argparse._StoreTrueAction)
|
||||
out[flag] = {"required": action.required, "kind": "boolean" if is_bool else "value"}
|
||||
return out
|
||||
|
||||
|
||||
_COMMAND_MODULES = {
|
||||
"release_inspect": inspect,
|
||||
"release_build_manifest": build_manifest,
|
||||
"release_sign": sign,
|
||||
"release_diff_remote": diff_remote,
|
||||
"release_upload_blobs": upload_blobs,
|
||||
"release_promote": promote,
|
||||
"release_verify_public": verify_public,
|
||||
"release_publish": publish,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool_name", sorted(EXPECTED_TOOL_NAMES))
|
||||
def test_schema_mirrors_argparse(tool_name):
|
||||
spec = TOOLS_BY_NAME[tool_name]
|
||||
mod = _COMMAND_MODULES[tool_name]
|
||||
argparse_flags = _argparse_flags_for(mod.add_parser)
|
||||
|
||||
spec_flags = {f.cli_flag: f for f in spec.fields}
|
||||
assert set(spec_flags) == set(argparse_flags), (
|
||||
f"{tool_name}: spec flags {set(spec_flags)} != argparse flags {set(argparse_flags)}"
|
||||
)
|
||||
for flag, info in argparse_flags.items():
|
||||
field = spec_flags[flag]
|
||||
assert field.required == info["required"], f"{tool_name} {flag} required mismatch"
|
||||
if info["kind"] == "boolean":
|
||||
assert field.kind == "boolean", f"{tool_name} {flag} expected boolean"
|
||||
else:
|
||||
assert field.kind != "boolean", f"{tool_name} {flag} should not be boolean"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool_name", sorted(EXPECTED_TOOL_NAMES))
|
||||
def test_required_fields_in_json_schema(tool_name):
|
||||
spec = TOOLS_BY_NAME[tool_name]
|
||||
schema = json_schema(spec)
|
||||
required_in_spec = {f.name for f in spec.fields if f.required}
|
||||
required_in_schema = set(schema.get("required", []))
|
||||
assert required_in_schema == required_in_spec
|
||||
|
||||
|
||||
def test_unknown_tool_rejected_by_dispatch():
|
||||
with pytest.raises(UnknownToolError):
|
||||
dispatch("nope_not_a_tool", {})
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_build_cli_args_rejects_missing_required():
|
||||
from metin_release_mcp.errors import InvalidToolInputError
|
||||
|
||||
spec = TOOLS_BY_NAME["release_inspect"]
|
||||
with pytest.raises(InvalidToolInputError):
|
||||
build_cli_args(spec, {})
|
||||
|
||||
|
||||
def test_build_cli_args_rejects_unknown_fields():
|
||||
from metin_release_mcp.errors import InvalidToolInputError
|
||||
|
||||
spec = TOOLS_BY_NAME["release_inspect"]
|
||||
with pytest.raises(InvalidToolInputError):
|
||||
build_cli_args(spec, {"source": "/x", "nonsense": 1})
|
||||
73
tests/test_build_manifest.py
Normal file
73
tests/test_build_manifest.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from metin_release.commands import build_manifest
|
||||
from metin_release.errors import ValidationError
|
||||
from metin_release.workspace import Context
|
||||
|
||||
|
||||
def _ctx(script: Path) -> Context:
|
||||
return Context(make_manifest_script=script)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not Path("/home/jann/metin/repos/m2dev-client/scripts/make-manifest.py").is_file(),
|
||||
reason="real make-manifest.py not available",
|
||||
)
|
||||
def test_build_manifest_real_script(tiny_client: Path, tmp_path: Path, make_manifest_script: Path):
|
||||
out = tmp_path / "manifest.json"
|
||||
ctx = _ctx(make_manifest_script)
|
||||
args = argparse.Namespace(
|
||||
source=tiny_client,
|
||||
version="2026.04.14-1",
|
||||
out=out,
|
||||
previous=None,
|
||||
notes=None,
|
||||
launcher="Metin2Launcher.exe",
|
||||
created_at="2026-04-14T00:00:00Z",
|
||||
)
|
||||
result = build_manifest.run(ctx, args)
|
||||
assert out.is_file()
|
||||
manifest = json.loads(out.read_text())
|
||||
assert manifest["version"] == "2026.04.14-1"
|
||||
assert manifest["launcher"]["path"] == "Metin2Launcher.exe"
|
||||
assert len(manifest["files"]) >= 3 # Metin2.exe, readme.txt, subdir/*
|
||||
stats = result.data["stats"]
|
||||
assert stats["file_count"] == len(manifest["files"]) + 1
|
||||
assert stats["blob_count"] >= 1
|
||||
assert stats["total_bytes"] > 0
|
||||
|
||||
|
||||
def test_build_manifest_missing_script(tiny_client: Path, tmp_path: Path):
|
||||
ctx = _ctx(tmp_path / "nope.py")
|
||||
args = argparse.Namespace(
|
||||
source=tiny_client,
|
||||
version="v1",
|
||||
out=tmp_path / "manifest.json",
|
||||
previous=None,
|
||||
notes=None,
|
||||
launcher="Metin2Launcher.exe",
|
||||
created_at=None,
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
build_manifest.run(ctx, args)
|
||||
|
||||
|
||||
def test_build_manifest_missing_source(tmp_path: Path, make_manifest_script: Path):
|
||||
ctx = _ctx(make_manifest_script)
|
||||
args = argparse.Namespace(
|
||||
source=tmp_path / "nope",
|
||||
version="v1",
|
||||
out=tmp_path / "manifest.json",
|
||||
previous=None,
|
||||
notes=None,
|
||||
launcher="Metin2Launcher.exe",
|
||||
created_at=None,
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
build_manifest.run(ctx, args)
|
||||
56
tests/test_cli_dispatch.py
Normal file
56
tests/test_cli_dispatch.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def _run(*args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "metin_release", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def test_version_flag():
|
||||
r = _run("--version")
|
||||
assert r.returncode == 0
|
||||
assert "metin-release" in r.stdout
|
||||
|
||||
|
||||
def test_help_top_level():
|
||||
r = _run("--help")
|
||||
assert r.returncode == 0
|
||||
assert "release" in r.stdout
|
||||
|
||||
|
||||
def test_help_release_inspect():
|
||||
r = _run("release", "inspect", "--help")
|
||||
assert r.returncode == 0
|
||||
assert "--source" in r.stdout
|
||||
|
||||
|
||||
def test_unknown_subcommand_exits_nonzero():
|
||||
r = _run("release", "does-not-exist")
|
||||
assert r.returncode != 0
|
||||
|
||||
|
||||
def test_inspect_json_output_is_pure_json(tmp_path):
|
||||
(tmp_path / "Metin2.exe").write_text("x")
|
||||
(tmp_path / "Metin2Launcher.exe").write_text("y")
|
||||
r = _run("--json", "release", "inspect", "--source", str(tmp_path))
|
||||
assert r.returncode == 0
|
||||
envelope = json.loads(r.stdout) # must parse cleanly
|
||||
assert envelope["ok"] is True
|
||||
assert envelope["command"] == "release inspect"
|
||||
assert envelope["stats"]["file_count"] == 2
|
||||
|
||||
|
||||
def test_inspect_human_mode_emits_json_on_stdout_and_summary_on_stderr(tmp_path):
|
||||
(tmp_path / "Metin2.exe").write_text("x")
|
||||
r = _run("release", "inspect", "--source", str(tmp_path))
|
||||
assert r.returncode == 0
|
||||
envelope = json.loads(r.stdout)
|
||||
assert envelope["ok"] is True
|
||||
assert "release inspect" in r.stderr
|
||||
71
tests/test_diff_remote.py
Normal file
71
tests/test_diff_remote.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Make fixtures importable.
|
||||
sys.path.insert(0, str(Path(__file__).parent / "fixtures"))
|
||||
from http_server import serve_dir # noqa: E402
|
||||
|
||||
from metin_release.commands import diff_remote # noqa: E402
|
||||
from metin_release.workspace import Context # noqa: E402
|
||||
|
||||
|
||||
def _write_manifest(path: Path, hashes: list[str]) -> None:
|
||||
manifest = {
|
||||
"version": "v1",
|
||||
"created_at": "2026-04-14T00:00:00Z",
|
||||
"launcher": {"path": "Metin2Launcher.exe", "sha256": hashes[0], "size": 1},
|
||||
"files": [
|
||||
{"path": f"file{i}", "sha256": h, "size": 1}
|
||||
for i, h in enumerate(hashes[1:])
|
||||
],
|
||||
}
|
||||
path.write_text(json.dumps(manifest, indent=2))
|
||||
|
||||
|
||||
def test_diff_remote_reports_missing(tmp_path: Path):
|
||||
hashes = [
|
||||
"a" * 64,
|
||||
"b" * 64,
|
||||
"c" * 64,
|
||||
]
|
||||
manifest = tmp_path / "manifest.json"
|
||||
_write_manifest(manifest, hashes)
|
||||
|
||||
# Remote serves only two of the three blobs.
|
||||
remote = tmp_path / "remote"
|
||||
remote.mkdir()
|
||||
for h in hashes[:2]:
|
||||
d = remote / "files" / h[:2]
|
||||
d.mkdir(parents=True)
|
||||
(d / h).write_bytes(b"x")
|
||||
|
||||
with serve_dir(remote) as base_url:
|
||||
args = argparse.Namespace(manifest=manifest, base_url=base_url, timeout=5.0)
|
||||
result = diff_remote.run(Context(), args)
|
||||
|
||||
stats = result.data["stats"]
|
||||
assert stats["manifest_blob_count"] == 3
|
||||
assert stats["missing_blob_count"] == 1
|
||||
assert stats["missing_blobs"] == [hashes[2]]
|
||||
|
||||
|
||||
def test_diff_remote_all_present(tmp_path: Path):
|
||||
hashes = ["d" * 64]
|
||||
manifest = tmp_path / "manifest.json"
|
||||
_write_manifest(manifest, hashes)
|
||||
|
||||
remote = tmp_path / "remote"
|
||||
(remote / "files" / "dd").mkdir(parents=True)
|
||||
(remote / "files" / "dd" / hashes[0]).write_bytes(b"x")
|
||||
|
||||
with serve_dir(remote) as base_url:
|
||||
args = argparse.Namespace(manifest=manifest, base_url=base_url, timeout=5.0)
|
||||
result = diff_remote.run(Context(), args)
|
||||
|
||||
assert result.data["stats"]["missing_blob_count"] == 0
|
||||
42
tests/test_inspect.py
Normal file
42
tests/test_inspect.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from metin_release.commands import inspect
|
||||
from metin_release.errors import SourceNotFoundError
|
||||
from metin_release.workspace import Context
|
||||
|
||||
|
||||
def test_inspect_tiny_client(tiny_client: Path):
|
||||
ctx = Context()
|
||||
args = argparse.Namespace(source=tiny_client)
|
||||
result = inspect.run(ctx, args)
|
||||
stats = result.data["stats"]
|
||||
assert stats["file_count"] == 5
|
||||
assert stats["launcher_present"] is True
|
||||
assert stats["main_exe_present"] is True
|
||||
assert stats["total_bytes"] > 0
|
||||
assert stats["source_path"] == str(tiny_client)
|
||||
|
||||
|
||||
def test_inspect_missing_source(tmp_path):
|
||||
ctx = Context()
|
||||
args = argparse.Namespace(source=tmp_path / "nope")
|
||||
with pytest.raises(SourceNotFoundError):
|
||||
inspect.run(ctx, args)
|
||||
|
||||
|
||||
def test_inspect_skips_excluded(tmp_path):
|
||||
(tmp_path / "Metin2.exe").write_text("x")
|
||||
(tmp_path / "Metin2Launcher.exe").write_text("y")
|
||||
log_dir = tmp_path / "log"
|
||||
log_dir.mkdir()
|
||||
(log_dir / "should_skip.txt").write_text("nope")
|
||||
(tmp_path / "foo.pdb").write_text("nope")
|
||||
ctx = Context()
|
||||
args = argparse.Namespace(source=tmp_path)
|
||||
result = inspect.run(ctx, args)
|
||||
assert result.data["stats"]["file_count"] == 2
|
||||
83
tests/test_publish_composite.py
Normal file
83
tests/test_publish_composite.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent / "fixtures"))
|
||||
from http_server import serve_dir # noqa: E402
|
||||
|
||||
from metin_release.commands import publish # noqa: E402
|
||||
from metin_release.workspace import Context # noqa: E402
|
||||
|
||||
|
||||
MAKE_MANIFEST = Path("/home/jann/metin/repos/m2dev-client/scripts/make-manifest.py")
|
||||
SIGN_MANIFEST = Path("/home/jann/metin/repos/m2dev-client/scripts/sign-manifest.py")
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not (MAKE_MANIFEST.is_file() and SIGN_MANIFEST.is_file()),
|
||||
reason="reference scripts not available",
|
||||
)
|
||||
def test_publish_end_to_end_local(tiny_client: Path, tmp_path: Path, test_keypair):
|
||||
key_path, pub_hex = test_keypair
|
||||
out_dir = tmp_path / "release"
|
||||
rsync_target_dir = tmp_path / "remote"
|
||||
rsync_target_dir.mkdir()
|
||||
rsync_target = str(rsync_target_dir) + "/"
|
||||
|
||||
ctx = Context(make_manifest_script=MAKE_MANIFEST, sign_manifest_script=SIGN_MANIFEST)
|
||||
|
||||
# We need verify-public to hit the rsync target as an HTTP server.
|
||||
# Start HTTP server pointing at rsync target BEFORE publish runs verify-public.
|
||||
with serve_dir(rsync_target_dir) as base_url:
|
||||
args = argparse.Namespace(
|
||||
source=tiny_client,
|
||||
version="2026.04.14-1",
|
||||
out=out_dir,
|
||||
key=key_path,
|
||||
rsync_target=rsync_target,
|
||||
base_url=base_url,
|
||||
public_key=pub_hex,
|
||||
previous=None,
|
||||
notes=None,
|
||||
launcher="Metin2Launcher.exe",
|
||||
created_at="2026-04-14T00:00:00Z",
|
||||
sample_blobs=2,
|
||||
yes=True,
|
||||
force=False,
|
||||
dry_run_upload=False,
|
||||
)
|
||||
result = publish.run(ctx, args)
|
||||
|
||||
assert result.ok is True
|
||||
assert result.status == "published"
|
||||
stages = result.data["stages"]
|
||||
names = [s["name"] for s in stages]
|
||||
assert names == [
|
||||
"build-manifest",
|
||||
"sign",
|
||||
"stage-blobs",
|
||||
"upload-blobs",
|
||||
"promote",
|
||||
"verify-public",
|
||||
]
|
||||
for s in stages:
|
||||
assert "error" not in s
|
||||
|
||||
# Verify that the rsync target now contains manifest + blobs.
|
||||
assert (rsync_target_dir / "manifest.json").is_file()
|
||||
assert (rsync_target_dir / "manifest.json.sig").is_file()
|
||||
assert (rsync_target_dir / "files").is_dir()
|
||||
blobs = list((rsync_target_dir / "files").rglob("*"))
|
||||
blob_files = [b for b in blobs if b.is_file()]
|
||||
assert len(blob_files) >= 3
|
||||
|
||||
# Verify sample-blobs count reflected
|
||||
verify_stats = result.data["stats"]["verify"]
|
||||
assert verify_stats["signature_valid"] is True
|
||||
assert verify_stats["sampled_blob_count"] == 2
|
||||
assert verify_stats["sampled_blob_failures"] == []
|
||||
73
tests/test_sign.py
Normal file
73
tests/test_sign.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
|
||||
from metin_release.commands import sign
|
||||
from metin_release.errors import IntegrityError, KeyPermissionError, ValidationError
|
||||
from metin_release.workspace import Context
|
||||
|
||||
|
||||
def _ctx(script: Path) -> Context:
|
||||
return Context(sign_manifest_script=script)
|
||||
|
||||
|
||||
def _write_toy_manifest(path: Path) -> None:
|
||||
path.write_bytes(json.dumps({"version": "test", "files": []}, indent=2).encode() + b"\n")
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not Path("/home/jann/metin/repos/m2dev-client/scripts/sign-manifest.py").is_file(),
|
||||
reason="real sign-manifest.py not available",
|
||||
)
|
||||
def test_sign_produces_valid_signature(tmp_path: Path, test_keypair, sign_manifest_script: Path):
|
||||
key_path, pub_hex = test_keypair
|
||||
manifest_path = tmp_path / "manifest.json"
|
||||
_write_toy_manifest(manifest_path)
|
||||
|
||||
ctx = _ctx(sign_manifest_script)
|
||||
args = argparse.Namespace(manifest=manifest_path, key=key_path, out=None)
|
||||
result = sign.run(ctx, args)
|
||||
|
||||
sig_path = Path(result.data["artifacts"]["signature_path"])
|
||||
assert sig_path.is_file()
|
||||
sig = sig_path.read_bytes()
|
||||
assert len(sig) == 64
|
||||
|
||||
# Verify it actually checks out against the public key.
|
||||
pub = Ed25519PublicKey.from_public_bytes(bytes.fromhex(pub_hex))
|
||||
pub.verify(sig, manifest_path.read_bytes())
|
||||
|
||||
# Now corrupt the manifest; signature no longer verifies.
|
||||
manifest_path.write_bytes(b"tampered")
|
||||
with pytest.raises(InvalidSignature):
|
||||
pub.verify(sig, manifest_path.read_bytes())
|
||||
|
||||
|
||||
def test_sign_rejects_loose_key_permissions(tmp_path: Path, sign_manifest_script: Path):
|
||||
key = tmp_path / "key"
|
||||
key.write_bytes(b"\x00" * 32)
|
||||
os.chmod(key, 0o644)
|
||||
manifest = tmp_path / "manifest.json"
|
||||
_write_toy_manifest(manifest)
|
||||
|
||||
ctx = _ctx(sign_manifest_script)
|
||||
args = argparse.Namespace(manifest=manifest, key=key, out=None)
|
||||
with pytest.raises(KeyPermissionError):
|
||||
sign.run(ctx, args)
|
||||
|
||||
|
||||
def test_sign_relative_key_path_rejected(tmp_path: Path, sign_manifest_script: Path):
|
||||
manifest = tmp_path / "manifest.json"
|
||||
_write_toy_manifest(manifest)
|
||||
ctx = _ctx(sign_manifest_script)
|
||||
args = argparse.Namespace(manifest=manifest, key=Path("relative/key"), out=None)
|
||||
with pytest.raises(ValidationError):
|
||||
sign.run(ctx, args)
|
||||
Reference in New Issue
Block a user