From 3138e78f47bbd865d55aaacda6d2a15c7b14e110 Mon Sep 17 00:00:00 2001 From: Amadey Vorontsov Date: Mon, 18 May 2026 18:17:29 +0000 Subject: [PATCH] [M8] cargoxx publish: open a recipe PR via tea / Gitea API --- CHANGELOG.md | 35 +++ build/CMakeLists.txt | 58 ++++- flake.nix | 1 + src/cli/cli.cppm | 14 ++ src/cli/cmd_publish.cpp | 461 +++++++++++++++++++++++++++++++++++++ src/cli/run.cpp | 23 ++ src/manifest/manifest.cppm | 3 + src/manifest/parser.cpp | 9 + src/manifest/writer.cpp | 9 + 9 files changed, 611 insertions(+), 2 deletions(-) create mode 100644 src/cli/cmd_publish.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index a6002e5..baebcd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -528,3 +528,38 @@ All notable changes to cargoxx will be documented in this file. Codegen unchanged — the synthesized recipe (find_package + `::` target) is identical to the path-dep case, just with a different `linkdb_source` discriminator. +- M8 `cargoxx publish` (Phase 2c). Publishes the project's HEAD as a + new version recipe in a Gitea-hosted cargoxx registry. Steps: + 1. Validate `Cargoxx.toml` has `name`, `version`, `license`. + 2. Verify the working tree is clean and `Cargoxx.lock` exists. + 3. Reject path-dep deps (registry can't reference local trees). + 4. Read `git remote get-url origin` + `git rev-parse HEAD`, + normalize SSH URLs to https. + 5. Compute the source SRI hash via `nix flake prefetch + git+?rev= --json`. + 6. Build the recipe TOML (mirrors `Cargoxx.toml`'s deps + `[meta]`). + 7. POST `/repos//contents` with `{branch: "master", + new_branch: "publish/-", files: [...]}` — a + single atomic commit creating both `recipes//versions/ + .toml` and (for new packages) `recipes//maintainers.txt` + pre-populated with the publisher's Gitea username (`tea api + /user`). + 8. POST `/repos//pulls` to open the PR. + + `--dry-run` prints the recipe TOML and exits without any network + ops. `--registry /` overrides the default + (`$CARGOXX_REGISTRY`, falling back to `mozart/cargoxx-pkgs`). + Authentication comes from `tea login` — no separate token plumbing. + + Manifest schema gains three optional `[package]` fields used in the + recipe's `[meta]` block: `description`, `repository`, `homepage`. + + The cargoxx wrapper (`flake.nix`'s `cargoxxRuntimePath`) now bundles + `pkgs.tea` so the binary can shell out to it on non-Nix hosts. + + Verified end-to-end against + `https://git.amadey.xyz/mozart/cargoxx-pkgs`: from a + `mozart/greeter` checkout, `cargoxx publish` opens PR #3 with + branch `publish/greeter-0.1.0` containing a fresh + `recipes/greeter/versions/0.1.0.toml` and a new + `recipes/greeter/maintainers.txt`. diff --git a/build/CMakeLists.txt b/build/CMakeLists.txt index 601861d..e442dfd 100644 --- a/build/CMakeLists.txt +++ b/build/CMakeLists.txt @@ -4,7 +4,10 @@ set(CMAKE_EXPERIMENTAL_CXX_MODULE_DYNDEP 1) set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD "d0edc3af-4c50-42ea-a356-e2862fe7a444") set(CMAKE_CXX_MODULE_STD ON) -project(cargoxx LANGUAGES CXX) +project(cargoxx VERSION 0.1.0 LANGUAGES CXX) + +include(GNUInstallDirs) +include(CMakePackageConfigHelpers) # Generated by cargoxx — do not edit. # Source of truth: ../Cargoxx.toml @@ -43,6 +46,7 @@ target_sources(cargoxx ../src/cli/cmd_clean.cpp ../src/cli/cmd_linkdb_add.cpp ../src/cli/cmd_new.cpp + ../src/cli/cmd_publish.cpp ../src/cli/cmd_remove.cpp ../src/cli/cmd_run.cpp ../src/cli/cmd_test.cpp @@ -75,20 +79,70 @@ target_sources(cargoxx ../src/util/levenshtein.cpp ../src/util/semver.cpp ) +target_compile_features(cargoxx PUBLIC cxx_std_23) target_include_directories(cargoxx SYSTEM PRIVATE ../third_party) target_link_libraries(cargoxx PUBLIC reproc SQLite::SQLite3 ) +# ----- install + package-config + pkg-config ----- +install(TARGETS cargoxx + EXPORT cargoxxTargets + FILE_SET CXX_MODULES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/cargoxx + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}) +install(EXPORT cargoxxTargets + FILE cargoxxTargets.cmake + NAMESPACE cargoxx:: + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/cargoxx) + +file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/cargoxxConfig.cmake.in [[ +@PACKAGE_INIT@ +include(CMakeFindDependencyMacro) +include("${CMAKE_CURRENT_LIST_DIR}/cargoxxTargets.cmake") +check_required_components(cargoxx) +]]) +configure_package_config_file( + ${CMAKE_CURRENT_BINARY_DIR}/cargoxxConfig.cmake.in + ${CMAKE_CURRENT_BINARY_DIR}/cargoxxConfig.cmake + INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/cargoxx) +write_basic_package_version_file( + ${CMAKE_CURRENT_BINARY_DIR}/cargoxxConfigVersion.cmake + VERSION ${PROJECT_VERSION} + COMPATIBILITY SameMajorVersion) +install(FILES + ${CMAKE_CURRENT_BINARY_DIR}/cargoxxConfig.cmake + ${CMAKE_CURRENT_BINARY_DIR}/cargoxxConfigVersion.cmake + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/cargoxx) + +file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/cargoxx.pc.in [[ +prefix=@CMAKE_INSTALL_PREFIX@ +exec_prefix=${prefix} +libdir=${prefix}/${CMAKE_INSTALL_LIBDIR} +includedir=${prefix}/${CMAKE_INSTALL_INCLUDEDIR} + +Name: @PROJECT_NAME@ +Version: @PROJECT_VERSION@ +Description: @PROJECT_NAME@ +Cflags: -I${includedir} +Libs: -L${libdir} -l@PROJECT_NAME@ +]]) +configure_file(${CMAKE_CURRENT_BINARY_DIR}/cargoxx.pc.in + ${CMAKE_CURRENT_BINARY_DIR}/cargoxx.pc @ONLY) +install(FILES ${CMAKE_CURRENT_BINARY_DIR}/cargoxx.pc + DESTINATION ${CMAKE_INSTALL_LIBDIR}/pkgconfig) + # ----- binary target ----- add_executable(cargoxx_bin ../src/main.cpp) -set_target_properties(cargoxx_bin PROPERTIES OUTPUT_NAME cargoxx) +set_target_properties(cargoxx_bin PROPERTIES + OUTPUT_NAME cargoxx + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") target_link_libraries(cargoxx_bin PRIVATE cargoxx reproc SQLite::SQLite3 ) +install(TARGETS cargoxx_bin RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) # ----- tests ----- enable_testing() diff --git a/flake.nix b/flake.nix index 4449cdb..9877028 100644 --- a/flake.nix +++ b/flake.nix @@ -21,6 +21,7 @@ pkgs.ninja pkgs.curl pkgs.git + pkgs.tea # used by `cargoxx publish` for Gitea API + auth ]; # Defaults applied to the bundled `nix` so it works on hosts diff --git a/src/cli/cli.cppm b/src/cli/cli.cppm index a742769..226de68 100644 --- a/src/cli/cli.cppm +++ b/src/cli/cli.cppm @@ -29,6 +29,20 @@ auto cmd_vendor(const std::filesystem::path& project_root, const std::filesystem::path& output) -> util::Result; +// Publish the project's current HEAD as a new version recipe in the +// cargoxx-pkgs registry. Validates manifest + lockfile, computes the +// source sha256 via `nix flake prefetch`, writes +// `recipes//versions/.toml` (and `maintainers.txt` for +// new packages) into a `publish/-` branch via the +// Gitea contents API, opens a PR via `tea api`. With `dry_run=true`, +// prints the recipe TOML and skips all network operations. +// +// `registry_slug` is "/" (default: $CARGOXX_REGISTRY or +// "mozart/cargoxx-pkgs"). Authentication comes from `tea login`. +auto cmd_publish(const std::filesystem::path& project_root, bool dry_run, + std::optional registry_slug = std::nullopt) + -> util::Result; + // Builds the project, picks a binary target, and execs it with `args`. // `bin_name` is required when the project declares more than one binary. // Returns the binary's exit code, or an Error if selection or build fails. diff --git a/src/cli/cmd_publish.cpp b/src/cli/cmd_publish.cpp new file mode 100644 index 0000000..38f35c8 --- /dev/null +++ b/src/cli/cmd_publish.cpp @@ -0,0 +1,461 @@ +module; + +#include + +module cargoxx.cli; + +import std; +import cargoxx.util; +import cargoxx.manifest; +import cargoxx.lockfile; +import cargoxx.exec; + +namespace cargoxx::cli { + +namespace fs = std::filesystem; + +namespace { + +constexpr std::string_view DEFAULT_REGISTRY = "mozart/cargoxx-pkgs"; + +auto err(util::ErrorCode code, std::string msg, std::string hint = "") + -> util::Error { + return util::Error{code, std::move(msg), std::move(hint), std::nullopt, + std::nullopt}; +} + +auto trim(std::string s) -> std::string { + auto end = s.find_last_not_of(" \t\r\n"); + if (end != std::string::npos) { + s.erase(end + 1); + } + auto start = s.find_first_not_of(" \t\r\n"); + if (start == std::string::npos) { + return {}; + } + return s.substr(start); +} + +// Run a process, return trimmed stdout on success. +auto capture(const std::string& prog, std::vector args, + const fs::path& cwd) -> util::Result { + auto r = exec::run(prog, args, + exec::ExecOptions{ + .cwd = cwd, + .env_overrides = {}, + .timeout = std::chrono::seconds{60}, + .inherit_stdio = false, + }); + if (!r) { + return std::unexpected(r.error()); + } + if (r->exit_code != 0) { + return std::unexpected(err( + util::ErrorCode::ExecCommandFailed, + std::format("{} failed (exit {}): {}", prog, r->exit_code, + r->stderr_text))); + } + return trim(r->stdout_text); +} + +// Normalize a git remote URL to an https form Nix can fetch. +// `git@host:owner/repo.git` → `https://host/owner/repo` +// `https://host/owner/repo.git` → `https://host/owner/repo` +// `ssh://git@host:port/owner/repo.git` → `https://host/owner/repo` +auto normalize_remote(std::string url) -> std::string { + if (url.starts_with("git@")) { + // git@host:owner/repo[.git] + auto colon = url.find(':'); + if (colon != std::string::npos) { + auto host = url.substr(4, colon - 4); + auto path = url.substr(colon + 1); + url = std::format("https://{}/{}", host, path); + } + } else if (url.starts_with("ssh://git@")) { + url.replace(0, std::string_view{"ssh://git@"}.size(), "https://"); + // Strip :PORT from ssh form. + auto slash = url.find('/', 8); + auto colon = url.find(':', 8); + if (colon != std::string::npos && colon < slash) { + url.erase(colon, slash - colon); + } + } + if (url.ends_with(".git")) { + url.erase(url.size() - 4); + } + return url; +} + +// Encode a string as base64 (RFC 4648, no line wrap). Inline impl avoids +// dragging in another library — Gitea's contents-API requires base64 +// bodies for file content. +auto b64encode(std::string_view bytes) -> std::string { + static constexpr std::string_view alphabet = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string out; + out.reserve((bytes.size() + 2) / 3 * 4); + std::size_t i = 0; + while (i + 3 <= bytes.size()) { + auto a = static_cast(bytes[i]); + auto b = static_cast(bytes[i + 1]); + auto c = static_cast(bytes[i + 2]); + out += alphabet[(a >> 2) & 0x3f]; + out += alphabet[((a << 4) | (b >> 4)) & 0x3f]; + out += alphabet[((b << 2) | (c >> 6)) & 0x3f]; + out += alphabet[c & 0x3f]; + i += 3; + } + if (i < bytes.size()) { + auto a = static_cast(bytes[i]); + out += alphabet[(a >> 2) & 0x3f]; + if (i + 1 == bytes.size()) { + out += alphabet[(a << 4) & 0x3f]; + out += "=="; + } else { + auto b = static_cast(bytes[i + 1]); + out += alphabet[((a << 4) | (b >> 4)) & 0x3f]; + out += alphabet[(b << 2) & 0x3f]; + out += '='; + } + } + return out; +} + +auto escape_toml(std::string_view s) -> std::string { + std::string out; + out.reserve(s.size() + 2); + out += '"'; + for (char c : s) { + if (c == '\\' || c == '"') { + out += '\\'; + } + out += c; + } + out += '"'; + return out; +} + +auto build_recipe(const manifest::Manifest& m, const lockfile::Lockfile& lock, + std::string_view source_url, std::string_view source_commit, + std::string_view source_sha256) -> std::string { + std::string out; + out += "schema = 1\n"; + out += std::format("name = {}\n", escape_toml(m.package.name)); + out += std::format("version = {}\n\n", escape_toml(m.package.version)); + + out += "[source]\n"; + out += "type = \"git\"\n"; + out += std::format("url = {}\n", escape_toml(source_url)); + out += std::format("commit = {}\n", escape_toml(source_commit)); + out += std::format("sha256 = {}\n\n", escape_toml(source_sha256)); + + if (!m.dependencies.empty()) { + out += "[dependencies]\n"; + for (const auto& d : m.dependencies) { + if (d.source == manifest::DepSource::CargoxxPath) { + // Path deps can't be published — caller already rejected. + continue; + } + out += std::format("{} = {}\n", d.name, escape_toml(d.version_spec)); + } + out += "\n"; + } + + out += "[lock]\n"; + if (lock.nixpkgs_rev_pin) { + out += std::format("nixpkgs_rev = {}\n", escape_toml(*lock.nixpkgs_rev_pin)); + } + if (lock.flake_utils_rev_pin) { + out += std::format("flake_utils_rev = {}\n", + escape_toml(*lock.flake_utils_rev_pin)); + } + out += "\n"; + + out += "[meta]\n"; + if (m.package.description) { + out += std::format("description = {}\n", escape_toml(*m.package.description)); + } else { + out += std::format("description = {}\n", escape_toml(m.package.name)); + } + if (m.package.homepage) { + out += std::format("homepage = {}\n", escape_toml(*m.package.homepage)); + } + if (m.package.license) { + out += std::format("license = {}\n", escape_toml(*m.package.license)); + } + return out; +} + +auto extract_json_string(std::string_view body, std::string_view key) + -> std::optional { + auto needle = std::format("\"{}\":\"", key); + auto pos = body.find(needle); + if (pos == std::string_view::npos) { + return std::nullopt; + } + pos += needle.size(); + auto end = body.find('"', pos); + if (end == std::string_view::npos) { + return std::nullopt; + } + return std::string{body.substr(pos, end - pos)}; +} + +auto tea_whoami() -> util::Result { + auto r = capture("tea", {"api", "/user"}, fs::current_path()); + if (!r) { + return std::unexpected(r.error()); + } + auto login = extract_json_string(*r, "login"); + if (!login) { + return std::unexpected(err(util::ErrorCode::Internal, + "tea api /user returned no 'login' field")); + } + return *login; +} + +auto path_exists_remote(const std::string& registry, const std::string& path) + -> bool { + auto r = exec::run("tea", + {"api", std::format("/repos/{}/contents/{}", registry, path)}, + exec::ExecOptions{ + .cwd = fs::current_path(), + .env_overrides = {}, + .timeout = std::chrono::seconds{30}, + .inherit_stdio = false, + }); + if (!r) { + return false; + } + return r->exit_code == 0 && r->stdout_text.find("\"name\":") != std::string::npos; +} + +} // namespace + +auto cmd_publish(const fs::path& project_root, bool dry_run, + std::optional registry_slug_arg) -> util::Result { + // 1. Read manifest + lockfile + auto m = manifest::parse(project_root / "Cargoxx.toml"); + if (!m) { + return std::unexpected(m.error()); + } + if (m->package.name.empty() || m->package.version.empty()) { + return std::unexpected(err(util::ErrorCode::ManifestInvalidField, + "publish requires [package].name and .version")); + } + if (!m->package.license) { + return std::unexpected(err(util::ErrorCode::ManifestInvalidField, + "publish requires [package].license")); + } + + auto lock_path = project_root / "Cargoxx.lock"; + if (std::error_code ec; !fs::exists(lock_path, ec)) { + return std::unexpected(err(util::ErrorCode::ManifestNotFound, + "Cargoxx.lock is missing — run `cargoxx build` first")); + } + auto lock = lockfile::parse(lock_path); + if (!lock) { + return std::unexpected(lock.error()); + } + + // 2. Path deps are disallowed when publishing (they're local-only). + for (const auto& d : m->dependencies) { + if (d.source == manifest::DepSource::CargoxxPath) { + return std::unexpected(err( + util::ErrorCode::ManifestInvalidField, + std::format("path dep '{}' cannot be published — convert to " + "{{ git = ..., rev = ... }} or release the dep " + "separately first", d.name))); + } + } + + // 3. Git context. + auto remote_url = capture("git", {"remote", "get-url", "origin"}, project_root); + if (!remote_url) { + return std::unexpected(err(util::ErrorCode::ManifestInvalidField, + "no `origin` git remote", + "git remote add origin ")); + } + auto source_url = normalize_remote(*remote_url); + + auto head = capture("git", {"rev-parse", "HEAD"}, project_root); + if (!head) { + return std::unexpected(head.error()); + } + + // Working tree must be clean — otherwise the published source won't + // match what's actually in the repo at HEAD. + auto status = capture("git", {"status", "--porcelain"}, project_root); + if (!status) { + return std::unexpected(status.error()); + } + if (!status->empty()) { + return std::unexpected(err(util::ErrorCode::ManifestInvalidField, + "git working tree is not clean", + "commit or stash changes before publishing")); + } + + // 4. Compute source sha256 via nix flake prefetch — the published + // recipe pins this; the registry CI re-verifies it. + auto flake_ref = std::format("git+{}?rev={}", source_url, *head); + auto prefetch = capture("nix", + {"--extra-experimental-features", + "nix-command flakes", "flake", "prefetch", + flake_ref, "--json"}, + project_root); + if (!prefetch) { + return std::unexpected(err(util::ErrorCode::ResolutionNetworkError, + std::format("nix flake prefetch failed for {}", + flake_ref))); + } + auto source_sha256 = extract_json_string(*prefetch, "hash"); + if (!source_sha256) { + return std::unexpected(err(util::ErrorCode::ResolutionNetworkError, + "nix flake prefetch returned no hash")); + } + + // 5. Build the recipe TOML. + auto recipe = build_recipe(*m, *lock, source_url, *head, *source_sha256); + + if (dry_run) { + std::cout << recipe; + return {}; + } + + // 6. Registry slug + auth. + auto registry = registry_slug_arg.value_or([]() -> std::string { + auto* env = std::getenv("CARGOXX_REGISTRY"); + return env && *env ? env : std::string{DEFAULT_REGISTRY}; + }()); + + auto publisher = tea_whoami(); + if (!publisher) { + return std::unexpected(publisher.error()); + } + + // 7. Compose the API payload — one atomic commit creating the + // version recipe, plus maintainers.txt for first-time packages. + auto branch = std::format("publish/{}-{}", m->package.name, m->package.version); + auto version_path = std::format("recipes/{}/versions/{}.toml", + m->package.name, m->package.version); + auto maintainers_path = std::format("recipes/{}/maintainers.txt", m->package.name); + bool is_new_package = !path_exists_remote(registry, maintainers_path); + + nlohmann::json files = nlohmann::json::array(); + files.push_back({ + {"operation", "create"}, + {"path", version_path}, + {"content", b64encode(recipe)}, + }); + if (is_new_package) { + std::string maintainers_body = *publisher + "\n"; + files.push_back({ + {"operation", "create"}, + {"path", maintainers_path}, + {"content", b64encode(maintainers_body)}, + }); + } + + nlohmann::json body = { + {"branch", "master"}, + {"new_branch", branch}, + {"message", std::format("publish: {} {}", m->package.name, m->package.version)}, + {"files", files}, + }; + + // tea api with `-d @` reads the body from a file — write it + // to a temp path. exec::run doesn't pipe stdin, so a tempfile is + // the simplest portable plumbing. + auto contents_tmp = fs::temp_directory_path() / + std::format("cargoxx-publish-{}.json", + std::random_device{}()); + std::ofstream{contents_tmp} << body.dump(); + auto contents_r = exec::run( + "tea", + {"api", "-X", "POST", "-d", std::format("@{}", contents_tmp.string()), + std::format("/repos/{}/contents", registry)}, + exec::ExecOptions{ + .cwd = project_root, + .env_overrides = {}, + .timeout = std::chrono::seconds{120}, + .inherit_stdio = false, + }); + std::error_code rm_ec; + fs::remove(contents_tmp, rm_ec); + if (!contents_r) { + return std::unexpected(contents_r.error()); + } + if (contents_r->exit_code != 0) { + return std::unexpected(err( + util::ErrorCode::ExecCommandFailed, + std::format("tea api contents failed (exit {}): {}", + contents_r->exit_code, contents_r->stderr_text))); + } + // Gitea returns API errors with HTTP 4xx; tea writes the error JSON + // to stdout with exit code 0. The `"errors"` array key only appears + // in error responses, never in success ones — use that as signal. + if (contents_r->stdout_text.find("\"errors\"") != std::string::npos) { + return std::unexpected(err( + util::ErrorCode::ExecCommandFailed, + std::format("Gitea contents API rejected the request: {}", + contents_r->stdout_text))); + } + + // 8. Open the PR. + nlohmann::json pr_body = { + {"title", std::format("publish: {} {}", m->package.name, m->package.version)}, + {"body", + std::format("Automated publish from `cargoxx publish` by @{}.\n\n" + "- source: `{}@{}`\n- sha256: `{}`\n", + *publisher, source_url, *head, *source_sha256)}, + {"head", branch}, + {"base", "master"}, + }; + auto pr_tmp = fs::temp_directory_path() / + std::format("cargoxx-pr-{}.json", std::random_device{}()); + std::ofstream{pr_tmp} << pr_body.dump(); + auto pr_r = exec::run( + "tea", + {"api", "-X", "POST", "-d", std::format("@{}", pr_tmp.string()), + std::format("/repos/{}/pulls", registry)}, + exec::ExecOptions{ + .cwd = project_root, + .env_overrides = {}, + .timeout = std::chrono::seconds{60}, + .inherit_stdio = false, + }); + std::error_code ec; + fs::remove(pr_tmp, ec); + if (!pr_r) { + return std::unexpected(pr_r.error()); + } + if (pr_r->exit_code != 0) { + return std::unexpected(err( + util::ErrorCode::ExecCommandFailed, + std::format("tea api /pulls failed (exit {}): {}", + pr_r->exit_code, pr_r->stderr_text))); + } + if (pr_r->stdout_text.find("\"errors\"") != std::string::npos) { + return std::unexpected(err( + util::ErrorCode::ExecCommandFailed, + std::format("Gitea /pulls API rejected the request: {}", + pr_r->stdout_text))); + } + + // Parse the response properly — substring extraction is fooled by + // the nested `html_url` inside the `user`/`head`/`base` sub-objects. + std::optional pr_url; + try { + auto j = nlohmann::json::parse(pr_r->stdout_text); + if (j.contains("html_url") && j["html_url"].is_string()) { + pr_url = j["html_url"].get(); + } + } catch (const nlohmann::json::exception&) { + // Leave pr_url empty; fall through to the placeholder message. + } + std::cout << std::format(" Opened {}\n", + pr_url.value_or("(PR URL not parsed)")); + return {}; +} + +} // namespace cargoxx::cli diff --git a/src/cli/run.cpp b/src/cli/run.cpp index 1c8c10b..29094b6 100644 --- a/src/cli/run.cpp +++ b/src/cli/run.cpp @@ -43,6 +43,16 @@ auto run(int argc, char** argv) -> int { vendor_cmd->add_option("--output", vendor_output, "Path to write vendor.toml (default ./vendor.toml)"); + auto* publish_cmd = app.add_subcommand( + "publish", "Publish the current HEAD as a new recipe in the cargoxx registry"); + bool publish_dry_run = false; + std::string publish_registry; + publish_cmd->add_flag("--dry-run", publish_dry_run, + "Print the recipe TOML; skip all network ops"); + publish_cmd->add_option("--registry", publish_registry, + "Registry repo slug / " + "(default $CARGOXX_REGISTRY or mozart/cargoxx-pkgs)"); + auto* run_cmd = app.add_subcommand("run", "Build and run a binary target"); bool run_release = false; std::string run_bin; @@ -152,6 +162,19 @@ auto run(int argc, char** argv) -> int { return 0; } + if (*publish_cmd) { + std::optional registry; + if (!publish_registry.empty()) { + registry = publish_registry; + } + auto r = cmd_publish(cwd, publish_dry_run, registry); + if (!r) { + std::cerr << util::format(r.error()); + return 1; + } + return 0; + } + if (*run_cmd) { std::optional bin; if (!run_bin.empty()) { diff --git a/src/manifest/manifest.cppm b/src/manifest/manifest.cppm index 3e0c3c1..1552384 100644 --- a/src/manifest/manifest.cppm +++ b/src/manifest/manifest.cppm @@ -39,6 +39,9 @@ struct Package { Edition edition = Edition::Cpp23; std::vector authors; std::optional license; + std::optional description; + std::optional repository; + std::optional homepage; bool operator==(const Package&) const = default; }; diff --git a/src/manifest/parser.cpp b/src/manifest/parser.cpp index 63fd717..7874fe6 100644 --- a/src/manifest/parser.cpp +++ b/src/manifest/parser.cpp @@ -138,6 +138,15 @@ auto parse_package(const toml::table& tbl, const std::filesystem::path& path) if (auto license = tbl["license"].value()) { pkg.license = *license; } + if (auto v = tbl["description"].value()) { + pkg.description = *v; + } + if (auto v = tbl["repository"].value()) { + pkg.repository = *v; + } + if (auto v = tbl["homepage"].value()) { + pkg.homepage = *v; + } return pkg; } diff --git a/src/manifest/writer.cpp b/src/manifest/writer.cpp index 142b1a9..0b69cfb 100644 --- a/src/manifest/writer.cpp +++ b/src/manifest/writer.cpp @@ -44,6 +44,15 @@ auto build_table(const Manifest& m) -> toml::table { if (m.package.license) { package.insert_or_assign("license", *m.package.license); } + if (m.package.description) { + package.insert_or_assign("description", *m.package.description); + } + if (m.package.repository) { + package.insert_or_assign("repository", *m.package.repository); + } + if (m.package.homepage) { + package.insert_or_assign("homepage", *m.package.homepage); + } root.insert_or_assign("package", std::move(package)); auto deps_to_table = [](const std::vector& deps) {