462 lines
17 KiB
C++
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
|