diff --git a/README.md b/README.md index 0e3a2ec..307415a 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ with. - `m2pack keygen` - `m2pack build` +- `m2pack diff` - `m2pack list` - `m2pack verify` - `m2pack extract` @@ -57,6 +58,7 @@ Exposed tools: - `pack_keygen` - `pack_build` +- `pack_diff` - `pack_list` - `pack_verify` - `pack_extract` @@ -148,6 +150,15 @@ Export a client config header for `m2dev-client-src/src/PackLib/M2PackKeys.h`: --output /path/to/m2dev-client-src/src/PackLib/M2PackKeys.h ``` +Diff a source tree against an archive: + +```bash +./build/m2pack diff \ + --left /path/to/client/root \ + --right out/client.m2p \ + --json +``` + ## Format summary - Single archive file with a fixed header diff --git a/docs/agent-setup.md b/docs/agent-setup.md index c607c8a..d2f13ce 100644 --- a/docs/agent-setup.md +++ b/docs/agent-setup.md @@ -60,6 +60,7 @@ python scripts/mcp_smoke_test.py - `pack_keygen` - `pack_build` +- `pack_diff` - `pack_list` - `pack_verify` - `pack_extract` diff --git a/docs/mcp.md b/docs/mcp.md index 3b974a7..8a01f17 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -44,6 +44,13 @@ Inputs: - `key_file` - `signing_secret_key_file` +### `pack_diff` + +Inputs: + +- `left` +- `right` + ### `pack_list` Inputs: diff --git a/mcp_server.mjs b/mcp_server.mjs index 238da5a..f559e7d 100644 --- a/mcp_server.mjs +++ b/mcp_server.mjs @@ -96,6 +96,16 @@ server.tool( ), ); +server.tool( + "pack_diff", + "Diff two directories and/or .m2p archives using normalized paths and plaintext hashes.", + { + left: z.string(), + right: z.string(), + }, + async ({ left, right }) => toolResult(runCli(["diff", "--left", left, "--right", right])), +); + server.tool( "pack_list", "List entries in an .m2p archive.", diff --git a/mcp_server.py b/mcp_server.py index 7ad672a..dfbe863 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -89,6 +89,12 @@ def pack_build( ) +@mcp.tool() +def pack_diff(left: str, right: str) -> dict[str, Any]: + """Diff two directories and/or .m2p archives using normalized paths and plaintext hashes.""" + return _run_cli("diff", "--left", left, "--right", right) + + @mcp.tool() def pack_list(archive: str) -> dict[str, Any]: """List entries in an .m2p archive.""" diff --git a/src/cli.cpp b/src/cli.cpp index 45f7a22..55faa4d 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -94,12 +95,151 @@ std::array load_master_key(const std::string& path) return key; } +struct SnapshotEntry +{ + std::uint64_t size = 0; + std::array hash {}; +}; + +using SnapshotMap = std::map; + +SnapshotMap snapshot_from_directory(const std::filesystem::path& root) +{ + SnapshotMap snapshot; + for (const auto& path : collect_files(root)) + { + const auto bytes = read_file(path.string()); + snapshot.emplace(normalize_path(root, path), SnapshotEntry { + .size = static_cast(bytes.size()), + .hash = hash_bytes(bytes), + }); + } + return snapshot; +} + +SnapshotMap snapshot_from_archive(const std::filesystem::path& archive_path) +{ + SnapshotMap snapshot; + const auto archive = load_archive(archive_path); + for (const auto& entry : archive.entries) + { + snapshot.emplace(entry.path, SnapshotEntry { + .size = entry.original_size, + .hash = entry.plaintext_hash, + }); + } + return snapshot; +} + +SnapshotMap load_snapshot(const std::string& input) +{ + const std::filesystem::path path(input); + if (std::filesystem::is_directory(path)) + { + return snapshot_from_directory(path); + } + + if (std::filesystem::is_regular_file(path) && path.extension() == ".m2p") + { + return snapshot_from_archive(path); + } + + fail("pack_diff supports directories or .m2p archives: " + input); +} + +void command_diff(const ParsedArgs& args) +{ + const auto left = require_option(args, "left"); + const auto right = require_option(args, "right"); + + const auto left_snapshot = load_snapshot(left); + const auto right_snapshot = load_snapshot(right); + + std::vector added; + std::vector removed; + std::vector changed; + std::size_t unchanged = 0; + + std::set all_paths; + for (const auto& [path, _] : left_snapshot) + { + all_paths.insert(path); + } + for (const auto& [path, _] : right_snapshot) + { + all_paths.insert(path); + } + + for (const auto& path : all_paths) + { + const auto lit = left_snapshot.find(path); + const auto rit = right_snapshot.find(path); + + if (lit == left_snapshot.end()) + { + added.push_back(path); + continue; + } + + if (rit == right_snapshot.end()) + { + removed.push_back(path); + continue; + } + + if (lit->second.size != rit->second.size || lit->second.hash != rit->second.hash) + { + changed.push_back(path); + continue; + } + + unchanged += 1; + } + + if (args.json) + { + auto render_array = [](const std::vector& values) { + std::ostringstream out; + out << "["; + for (std::size_t i = 0; i < values.size(); ++i) + { + if (i != 0) + { + out << ","; + } + out << "\"" << json_escape(values[i]) << "\""; + } + out << "]"; + return out.str(); + }; + + std::cout + << "{" + << "\"ok\":true," + << "\"left\":\"" << json_escape(left) << "\"," + << "\"right\":\"" << json_escape(right) << "\"," + << "\"added\":" << render_array(added) << "," + << "\"removed\":" << render_array(removed) << "," + << "\"changed\":" << render_array(changed) << "," + << "\"unchanged_count\":" << unchanged + << "}\n"; + return; + } + + std::cout + << "Added: " << added.size() << "\n" + << "Removed: " << removed.size() << "\n" + << "Changed: " << changed.size() << "\n" + << "Unchanged: " << unchanged << "\n"; +} + void print_usage() { std::cout << "m2pack commands:\n" << " keygen --out-dir [--json]\n" << " build --input --output --key --sign-secret-key [--json]\n" + << " diff --left --right [--json]\n" << " list --archive [--json]\n" << " verify --archive [--public-key ] [--key ] [--json]\n" << " extract --archive --output --key [--json]\n" @@ -359,6 +499,11 @@ int run_cli(int argc, char** argv) command_list(args); return 0; } + if (args.command == "diff") + { + command_diff(args); + return 0; + } if (args.command == "verify") { command_verify(args);