[M8] cargoxx publish: open a recipe PR via tea / Gitea API

This commit is contained in:
2026-05-18 18:17:29 +00:00
parent 09f151ad82
commit 3138e78f47
9 changed files with 611 additions and 2 deletions

View File

@@ -528,3 +528,38 @@ All notable changes to cargoxx will be documented in this file.
Codegen unchanged — the synthesized recipe (find_package + Codegen unchanged — the synthesized recipe (find_package +
`<name>::<name>` target) is identical to the path-dep case, just `<name>::<name>` target) is identical to the path-dep case, just
with a different `linkdb_source` discriminator. 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+<url>?rev=<commit> --json`.
6. Build the recipe TOML (mirrors `Cargoxx.toml`'s deps + `[meta]`).
7. POST `/repos/<registry>/contents` with `{branch: "master",
new_branch: "publish/<name>-<version>", files: [...]}` — a
single atomic commit creating both `recipes/<name>/versions/
<v>.toml` and (for new packages) `recipes/<name>/maintainers.txt`
pre-populated with the publisher's Gitea username (`tea api
/user`).
8. POST `/repos/<registry>/pulls` to open the PR.
`--dry-run` prints the recipe TOML and exits without any network
ops. `--registry <owner>/<repo>` 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`.

View File

@@ -4,7 +4,10 @@ set(CMAKE_EXPERIMENTAL_CXX_MODULE_DYNDEP 1)
set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD "d0edc3af-4c50-42ea-a356-e2862fe7a444") set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD "d0edc3af-4c50-42ea-a356-e2862fe7a444")
set(CMAKE_CXX_MODULE_STD ON) 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. # Generated by cargoxx — do not edit.
# Source of truth: ../Cargoxx.toml # Source of truth: ../Cargoxx.toml
@@ -43,6 +46,7 @@ target_sources(cargoxx
../src/cli/cmd_clean.cpp ../src/cli/cmd_clean.cpp
../src/cli/cmd_linkdb_add.cpp ../src/cli/cmd_linkdb_add.cpp
../src/cli/cmd_new.cpp ../src/cli/cmd_new.cpp
../src/cli/cmd_publish.cpp
../src/cli/cmd_remove.cpp ../src/cli/cmd_remove.cpp
../src/cli/cmd_run.cpp ../src/cli/cmd_run.cpp
../src/cli/cmd_test.cpp ../src/cli/cmd_test.cpp
@@ -75,20 +79,70 @@ target_sources(cargoxx
../src/util/levenshtein.cpp ../src/util/levenshtein.cpp
../src/util/semver.cpp ../src/util/semver.cpp
) )
target_compile_features(cargoxx PUBLIC cxx_std_23)
target_include_directories(cargoxx SYSTEM PRIVATE ../third_party) target_include_directories(cargoxx SYSTEM PRIVATE ../third_party)
target_link_libraries(cargoxx PUBLIC target_link_libraries(cargoxx PUBLIC
reproc reproc
SQLite::SQLite3 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 ----- # ----- binary target -----
add_executable(cargoxx_bin ../src/main.cpp) 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 target_link_libraries(cargoxx_bin PRIVATE
cargoxx cargoxx
reproc reproc
SQLite::SQLite3 SQLite::SQLite3
) )
install(TARGETS cargoxx_bin RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
# ----- tests ----- # ----- tests -----
enable_testing() enable_testing()

View File

@@ -21,6 +21,7 @@
pkgs.ninja pkgs.ninja
pkgs.curl pkgs.curl
pkgs.git pkgs.git
pkgs.tea # used by `cargoxx publish` for Gitea API + auth
]; ];
# Defaults applied to the bundled `nix` so it works on hosts # Defaults applied to the bundled `nix` so it works on hosts

View File

@@ -29,6 +29,20 @@ auto cmd_vendor(const std::filesystem::path& project_root,
const std::filesystem::path& output) const std::filesystem::path& output)
-> util::Result<void>; -> util::Result<void>;
// 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/<name>/versions/<version>.toml` (and `maintainers.txt` for
// new packages) into a `publish/<name>-<version>` 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 "<owner>/<repo>" (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<std::string> registry_slug = std::nullopt)
-> util::Result<void>;
// Builds the project, picks a binary target, and execs it with `args`. // Builds the project, picks a binary target, and execs it with `args`.
// `bin_name` is required when the project declares more than one binary. // `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. // Returns the binary's exit code, or an Error if selection or build fails.

461
src/cli/cmd_publish.cpp Normal file
View File

@@ -0,0 +1,461 @@
module;
#include <json.hpp>
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<std::string> args,
const fs::path& cwd) -> util::Result<std::string> {
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<unsigned char>(bytes[i]);
auto b = static_cast<unsigned char>(bytes[i + 1]);
auto c = static_cast<unsigned char>(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<unsigned char>(bytes[i]);
out += alphabet[(a >> 2) & 0x3f];
if (i + 1 == bytes.size()) {
out += alphabet[(a << 4) & 0x3f];
out += "==";
} else {
auto b = static_cast<unsigned char>(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<std::string> {
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<std::string> {
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<std::string> registry_slug_arg) -> util::Result<void> {
// 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 <url>"));
}
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 @<file>` 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<std::string> 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<std::string>();
}
} 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

View File

@@ -43,6 +43,16 @@ auto run(int argc, char** argv) -> int {
vendor_cmd->add_option("--output", vendor_output, vendor_cmd->add_option("--output", vendor_output,
"Path to write vendor.toml (default ./vendor.toml)"); "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 <owner>/<repo> "
"(default $CARGOXX_REGISTRY or mozart/cargoxx-pkgs)");
auto* run_cmd = app.add_subcommand("run", "Build and run a binary target"); auto* run_cmd = app.add_subcommand("run", "Build and run a binary target");
bool run_release = false; bool run_release = false;
std::string run_bin; std::string run_bin;
@@ -152,6 +162,19 @@ auto run(int argc, char** argv) -> int {
return 0; return 0;
} }
if (*publish_cmd) {
std::optional<std::string> 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) { if (*run_cmd) {
std::optional<std::string> bin; std::optional<std::string> bin;
if (!run_bin.empty()) { if (!run_bin.empty()) {

View File

@@ -39,6 +39,9 @@ struct Package {
Edition edition = Edition::Cpp23; Edition edition = Edition::Cpp23;
std::vector<std::string> authors; std::vector<std::string> authors;
std::optional<std::string> license; std::optional<std::string> license;
std::optional<std::string> description;
std::optional<std::string> repository;
std::optional<std::string> homepage;
bool operator==(const Package&) const = default; bool operator==(const Package&) const = default;
}; };

View File

@@ -138,6 +138,15 @@ auto parse_package(const toml::table& tbl, const std::filesystem::path& path)
if (auto license = tbl["license"].value<std::string>()) { if (auto license = tbl["license"].value<std::string>()) {
pkg.license = *license; pkg.license = *license;
} }
if (auto v = tbl["description"].value<std::string>()) {
pkg.description = *v;
}
if (auto v = tbl["repository"].value<std::string>()) {
pkg.repository = *v;
}
if (auto v = tbl["homepage"].value<std::string>()) {
pkg.homepage = *v;
}
return pkg; return pkg;
} }

View File

@@ -44,6 +44,15 @@ auto build_table(const Manifest& m) -> toml::table {
if (m.package.license) { if (m.package.license) {
package.insert_or_assign("license", *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)); root.insert_or_assign("package", std::move(package));
auto deps_to_table = [](const std::vector<Dependency>& deps) { auto deps_to_table = [](const std::vector<Dependency>& deps) {