Files
cargoxx/src/cli/cmd_publish.cpp

462 lines
17 KiB
C++

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