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