|
| 1 | +// mcpp.pm.publisher — generate xpkg Lua entry from mcpp.toml + scanner. |
| 2 | +// |
| 3 | +// See docs/04-schema-xpkg-extension.md for the produced layout. |
| 4 | + |
| 5 | +module; |
| 6 | +#include <cstdio> // popen / pclose / fgets |
| 7 | + |
| 8 | +export module mcpp.pm.publisher; |
| 9 | + |
| 10 | +import std; |
| 11 | +import mcpp.manifest; |
| 12 | +import mcpp.modgraph.graph; |
| 13 | + |
| 14 | +export namespace mcpp::pm { |
| 15 | + |
| 16 | +struct ReleaseInfo { |
| 17 | + std::string version; // tag/version, e.g. "0.1.0" |
| 18 | + |
| 19 | + struct PerPlatform { |
| 20 | + std::string url; |
| 21 | + std::string sha256; |
| 22 | + }; |
| 23 | + PerPlatform linux; |
| 24 | + PerPlatform macosx; |
| 25 | + PerPlatform windows; |
| 26 | +}; |
| 27 | + |
| 28 | +// Generate the xpkg Lua content for a package. |
| 29 | +std::string emit_xpkg(const mcpp::manifest::Manifest& manifest, |
| 30 | + const mcpp::modgraph::Graph& graph, |
| 31 | + const ReleaseInfo& release); |
| 32 | + |
| 33 | +// Convenience: synthesize a placeholder ReleaseInfo for `mcpp emit xpkg --version V` |
| 34 | +// before publish infrastructure exists. Uses {url, sha256} sentinels. |
| 35 | +ReleaseInfo placeholder_release(std::string_view version); |
| 36 | + |
| 37 | +// Compute the convention-based GitHub Release tarball URL for a package: |
| 38 | +// "<repo>/releases/download/v<version>/<name>-<version>.tar.gz" |
| 39 | +// Returns empty string if `repo` is empty or doesn't look like a https URL. |
| 40 | +std::string release_tarball_url(std::string_view repo, |
| 41 | + std::string_view name, |
| 42 | + std::string_view version); |
| 43 | + |
| 44 | +// Compute SHA-256 of `file` by shelling out to `sha256sum` (universally |
| 45 | +// available on Linux). Returns empty string on failure. |
| 46 | +std::string sha256_of_file(const std::filesystem::path& file); |
| 47 | + |
| 48 | +// Pack the package source tree at `root` into a tarball at `output` using |
| 49 | +// `git archive` (so .gitignore'd files are excluded automatically). The |
| 50 | +// tarball uses prefix "<name>-<version>/" so unpacking yields a clean |
| 51 | +// versioned directory. |
| 52 | +// |
| 53 | +// Requires the project to be in a git repo. |
| 54 | +// |
| 55 | +// Returns a non-empty error message on failure (empty on success). |
| 56 | +std::string make_release_tarball(const std::filesystem::path& root, |
| 57 | + std::string_view name, |
| 58 | + std::string_view version, |
| 59 | + const std::filesystem::path& output); |
| 60 | + |
| 61 | +// Convenience: build a real ReleaseInfo for v0.0.3-style local publish |
| 62 | +// where all three platforms point at the same source tarball. Caller has |
| 63 | +// already produced the tarball + sha256 by other means. |
| 64 | +ReleaseInfo make_release_info(std::string_view version, |
| 65 | + std::string_view url, |
| 66 | + std::string_view sha256); |
| 67 | + |
| 68 | +} // namespace mcpp::pm |
| 69 | + |
| 70 | +namespace mcpp::pm { |
| 71 | + |
| 72 | +namespace { |
| 73 | + |
| 74 | +// Quote `s` as a Lua double-quoted string literal: `"..."`. |
| 75 | +// |
| 76 | +// We deliberately use `"..."` (not the long-bracket `[[...]]` form) |
| 77 | +// so the only meta-characters that need escaping are the standard set |
| 78 | +// for `"` strings: |
| 79 | +// - `"` and `\\` must be backslash-escaped |
| 80 | +// - newline / carriage-return / NUL break the string literal |
| 81 | +// - other control bytes are escaped numerically (`\xHH`) for safety |
| 82 | +// so the emitted .lua is purely printable ASCII even when the input |
| 83 | +// contains exotic bytes |
| 84 | +// |
| 85 | +// Long-bracket sequences like `]=]` are NOT a vector here because we |
| 86 | +// never emit `[[`/`]=]` ourselves — the output is always `"..."`. |
| 87 | +std::string lua_escape(std::string_view s) { |
| 88 | + std::string out; |
| 89 | + out.reserve(s.size() + 2); |
| 90 | + out.push_back('"'); |
| 91 | + for (unsigned char c : s) { |
| 92 | + switch (c) { |
| 93 | + case '"': out += "\\\""; break; |
| 94 | + case '\\': out += "\\\\"; break; |
| 95 | + case '\n': out += "\\n"; break; |
| 96 | + case '\r': out += "\\r"; break; |
| 97 | + case '\t': out += "\\t"; break; |
| 98 | + case 0: out += "\\0"; break; |
| 99 | + default: |
| 100 | + if (c < 0x20 || c == 0x7f) { |
| 101 | + // Other C0 controls + DEL — emit as \xHH to keep the |
| 102 | + // .lua text purely printable. |
| 103 | + char buf[5]; |
| 104 | + std::snprintf(buf, sizeof(buf), "\\x%02x", c); |
| 105 | + out += buf; |
| 106 | + } else { |
| 107 | + out.push_back(static_cast<char>(c)); |
| 108 | + } |
| 109 | + break; |
| 110 | + } |
| 111 | + } |
| 112 | + out.push_back('"'); |
| 113 | + return out; |
| 114 | +} |
| 115 | + |
| 116 | +std::string platform_block(std::string_view version, const ReleaseInfo::PerPlatform& pp) { |
| 117 | + return std::format( |
| 118 | + " ['{0}'] = {{ url = {1}, sha256 = {2} }},\n", |
| 119 | + version, lua_escape(pp.url), lua_escape(pp.sha256)); |
| 120 | +} |
| 121 | + |
| 122 | +} // namespace |
| 123 | + |
| 124 | +std::string emit_xpkg(const mcpp::manifest::Manifest& manifest, |
| 125 | + const mcpp::modgraph::Graph& graph, |
| 126 | + const ReleaseInfo& release) |
| 127 | +{ |
| 128 | + std::string out; |
| 129 | + out += "-- AUTO-GENERATED by `mcpp emit xpkg`. Do not edit by hand.\n"; |
| 130 | + out += std::format("-- Source: mcpp.toml @ v{}\n", release.version); |
| 131 | + out += "package = {\n"; |
| 132 | + out += " spec = \"1\",\n"; |
| 133 | + out += std::format(" name = {},\n", lua_escape(manifest.package.name)); |
| 134 | + if (!manifest.package.description.empty()) |
| 135 | + out += std::format(" description = {},\n", lua_escape(manifest.package.description)); |
| 136 | + if (!manifest.package.license.empty()) |
| 137 | + out += std::format(" licenses = {{{}}},\n", lua_escape(manifest.package.license)); |
| 138 | + if (!manifest.package.repo.empty()) |
| 139 | + out += std::format(" repo = {},\n", lua_escape(manifest.package.repo)); |
| 140 | + out += " type = \"package\",\n\n"; |
| 141 | + |
| 142 | + out += " xpm = {\n"; |
| 143 | + out += " linux = {\n" + platform_block(release.version, release.linux) + " },\n"; |
| 144 | + out += " macosx = {\n" + platform_block(release.version, release.macosx) + " },\n"; |
| 145 | + out += " windows = {\n" + platform_block(release.version, release.windows) + " },\n"; |
| 146 | + out += " },\n\n"; |
| 147 | + |
| 148 | + out += " mcpp = {\n"; |
| 149 | + out += " schema = \"0.1\",\n"; |
| 150 | + out += std::format(" language = {},\n", lua_escape(manifest.language.standard)); |
| 151 | + out += std::format(" import_std = {},\n", manifest.language.importStd ? "true" : "false"); |
| 152 | + |
| 153 | + // Module list (from scanner) |
| 154 | + out += " modules = {\n"; |
| 155 | + for (auto& u : graph.units) { |
| 156 | + if (!u.provides) continue; |
| 157 | + // Skip partition-only units: their logical name contains ':' |
| 158 | + if (u.provides->logicalName.find(':') != std::string::npos) continue; |
| 159 | + out += std::format(" {},\n", lua_escape(u.provides->logicalName)); |
| 160 | + } |
| 161 | + out += " },\n"; |
| 162 | + |
| 163 | + // Dependencies (excluding dev-dependencies). Path-based deps are |
| 164 | + // local-only and intentionally not exposed in the published xpkg |
| 165 | + // descriptor; only version-based deps are emitted. |
| 166 | + out += " deps = {\n"; |
| 167 | + for (auto& [k, v] : manifest.dependencies) { |
| 168 | + if (v.isPath() || v.version.empty()) continue; |
| 169 | + out += std::format(" [{}] = {},\n", lua_escape(k), lua_escape(v.version)); |
| 170 | + } |
| 171 | + out += " },\n"; |
| 172 | + |
| 173 | + out += " manifest = \"mcpp.toml\",\n"; |
| 174 | + out += " },\n"; |
| 175 | + out += "}\n"; |
| 176 | + return out; |
| 177 | +} |
| 178 | + |
| 179 | +ReleaseInfo placeholder_release(std::string_view version) { |
| 180 | + ReleaseInfo r; |
| 181 | + r.version = std::string(version); |
| 182 | + auto fill = [&](ReleaseInfo::PerPlatform& pp, std::string_view ext) { |
| 183 | + pp.url = std::format("<TBD: release tarball URL>.{}", ext); |
| 184 | + pp.sha256 = "<TBD: sha256>"; |
| 185 | + }; |
| 186 | + fill(r.linux, "tar.gz"); |
| 187 | + fill(r.macosx, "tar.gz"); |
| 188 | + fill(r.windows, "zip"); |
| 189 | + return r; |
| 190 | +} |
| 191 | + |
| 192 | +std::string release_tarball_url(std::string_view repo, |
| 193 | + std::string_view name, |
| 194 | + std::string_view version) |
| 195 | +{ |
| 196 | + // Strip trailing ".git" if present. |
| 197 | + std::string r{repo}; |
| 198 | + if (r.ends_with(".git")) r.resize(r.size() - 4); |
| 199 | + if (r.empty()) return {}; |
| 200 | + if (!r.starts_with("https://") && !r.starts_with("http://")) return {}; |
| 201 | + return std::format("{}/releases/download/v{}/{}-{}.tar.gz", |
| 202 | + r, version, name, version); |
| 203 | +} |
| 204 | + |
| 205 | +std::string sha256_of_file(const std::filesystem::path& file) { |
| 206 | + if (!std::filesystem::exists(file)) return {}; |
| 207 | + auto cmd = std::format("sha256sum '{}' 2>/dev/null", file.string()); |
| 208 | + std::FILE* fp = ::popen(cmd.c_str(), "r"); |
| 209 | + if (!fp) return {}; |
| 210 | + std::array<char, 256> buf{}; |
| 211 | + std::string out; |
| 212 | + while (std::fgets(buf.data(), buf.size(), fp)) |
| 213 | + out += buf.data(); |
| 214 | + int rc = ::pclose(fp); |
| 215 | + if (rc != 0) return {}; |
| 216 | + // sha256sum format: "<64-hex> <filename>\n" |
| 217 | + auto sp = out.find(' '); |
| 218 | + if (sp == std::string::npos || sp != 64) return {}; |
| 219 | + return out.substr(0, 64); |
| 220 | +} |
| 221 | + |
| 222 | +std::string make_release_tarball(const std::filesystem::path& root, |
| 223 | + std::string_view name, |
| 224 | + std::string_view version, |
| 225 | + const std::filesystem::path& output) |
| 226 | +{ |
| 227 | + std::error_code ec; |
| 228 | + std::filesystem::create_directories(output.parent_path(), ec); |
| 229 | + |
| 230 | + auto cmd = std::format( |
| 231 | + "git -C '{}' archive --format=tar.gz " |
| 232 | + "--prefix='{}-{}/' " |
| 233 | + "-o '{}' HEAD 2>&1", |
| 234 | + root.string(), name, version, output.string()); |
| 235 | + std::FILE* fp = ::popen(cmd.c_str(), "r"); |
| 236 | + if (!fp) return std::format("popen failed for git archive: {}", cmd); |
| 237 | + |
| 238 | + std::array<char, 4096> buf{}; |
| 239 | + std::string err; |
| 240 | + while (std::fgets(buf.data(), buf.size(), fp)) |
| 241 | + err += buf.data(); |
| 242 | + int rc = ::pclose(fp); |
| 243 | + if (rc != 0) { |
| 244 | + return std::format("git archive failed (rc={}): {}", rc, err); |
| 245 | + } |
| 246 | + if (!std::filesystem::exists(output)) { |
| 247 | + return std::format("git archive exited 0 but no tarball at '{}'", |
| 248 | + output.string()); |
| 249 | + } |
| 250 | + return {}; |
| 251 | +} |
| 252 | + |
| 253 | +ReleaseInfo make_release_info(std::string_view version, |
| 254 | + std::string_view url, |
| 255 | + std::string_view sha256) |
| 256 | +{ |
| 257 | + ReleaseInfo r; |
| 258 | + r.version = std::string(version); |
| 259 | + auto fill = [&](ReleaseInfo::PerPlatform& pp) { |
| 260 | + pp.url = std::string(url); |
| 261 | + pp.sha256 = std::string(sha256); |
| 262 | + }; |
| 263 | + fill(r.linux); |
| 264 | + fill(r.macosx); |
| 265 | + fill(r.windows); |
| 266 | + return r; |
| 267 | +} |
| 268 | + |
| 269 | +} // namespace mcpp::pm |
0 commit comments