Add CUB package builder and include in all Red Bear OS configs

CUB (Red Bear OS Package Builder) is a Rust CLI tool that combines package management and building:
- RBPKGBUILD parser (TOML format) with full spec support
- Cookbook adapter converting RBPKGBUILD to recipe.toml
- PKGBUILD (Arch AUR) to RBPKGBUILD conversion with Linuxism detection
- Dependency mapping (Arch to Redox names)
- pkgar package creation integration
- Build environment setup with Cookbook env vars
- CLI with pacman-style shortcuts: -S, -Ss, -B, -G, -Pi, -Sua, -Sc, --import-aur

28 cub-lib tests passing. cub-cli compiles with local pkgutils.
Added cub = {} to redbear-desktop, redbear-full, redbear-minimal configs.
Created recipe symlink and updated integrate-redbear.sh.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-04-12 23:51:48 +01:00
parent ca13795f06
commit 59d4ba5dcf
19 changed files with 2789 additions and 0 deletions
@@ -0,0 +1,324 @@
use serde_derive::Serialize;
use crate::error::CubError;
use crate::rbpkgbuild::{BuildTemplate, RbPkgBuild, SourceType};
#[derive(Debug, Serialize)]
struct CookbookRecipe {
#[serde(skip_serializing_if = "Option::is_none")]
source: Option<CookbookSource>,
build: CookbookBuild,
#[serde(skip_serializing_if = "Option::is_none")]
package: Option<CookbookPackage>,
}
#[derive(Debug, Default, Serialize)]
struct CookbookSource {
#[serde(skip_serializing_if = "Option::is_none")]
git: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tar: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
rev: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
blake3: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
patches: Vec<String>,
}
#[derive(Debug, Serialize)]
struct CookbookBuild {
template: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
dependencies: Vec<String>,
#[serde(rename = "dev-dependencies", skip_serializing_if = "Vec::is_empty")]
dev_dependencies: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
cargoflags: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
configureflags: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
cmakeflags: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
mesonflags: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
script: Option<String>,
}
#[derive(Debug, Serialize)]
struct CookbookPackage {
#[serde(skip_serializing_if = "Vec::is_empty")]
dependencies: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
}
pub fn generate_recipe(rbpkg: &RbPkgBuild) -> Result<String, CubError> {
rbpkg.validate()?;
if rbpkg.source.sources.len() > 1 {
return Err(CubError::Conversion(
"Cookbook recipe generation currently supports a single primary source".to_string(),
));
}
let source = rbpkg
.source
.sources
.first()
.map(convert_source)
.transpose()?
.map(|mut source| {
source.patches = rbpkg.patches.files.clone();
source
});
let build = convert_build(rbpkg)?;
let package = build_package_section(rbpkg);
toml::to_string_pretty(&CookbookRecipe {
source,
build,
package,
})
.map_err(CubError::from)
}
fn convert_source(source: &crate::rbpkgbuild::SourceEntry) -> Result<CookbookSource, CubError> {
let mut cookbook = CookbookSource::default();
match source.source_type {
SourceType::Git => {
cookbook.git = Some(source.url.clone());
cookbook.branch = non_empty(&source.branch);
cookbook.rev = non_empty(&source.rev);
}
SourceType::Tar => {
cookbook.tar = Some(source.url.clone());
cookbook.blake3 = non_empty(&source.sha256);
}
}
Ok(cookbook)
}
fn convert_build(rbpkg: &RbPkgBuild) -> Result<CookbookBuild, CubError> {
let mut build = CookbookBuild {
template: template_name(&rbpkg.build.template).to_string(),
dependencies: rbpkg.dependencies.build.clone(),
dev_dependencies: rbpkg.dependencies.check.clone(),
cargoflags: Vec::new(),
configureflags: Vec::new(),
cmakeflags: Vec::new(),
mesonflags: Vec::new(),
script: None,
};
match rbpkg.build.template {
BuildTemplate::Cargo => {
if rbpkg.build.release {
build.cargoflags.push("--release".to_string());
}
if !rbpkg.build.features.is_empty() {
build.cargoflags.push("--features".to_string());
build.cargoflags.push(rbpkg.build.features.join(","));
}
build.cargoflags.extend(rbpkg.build.args.clone());
}
BuildTemplate::Configure => build.configureflags = rbpkg.build.args.clone(),
BuildTemplate::Cmake => build.cmakeflags = rbpkg.build.args.clone(),
BuildTemplate::Meson => build.mesonflags = rbpkg.build.args.clone(),
BuildTemplate::Custom => {
let script = custom_script(rbpkg)?;
build.script = Some(script);
}
}
Ok(build)
}
fn build_package_section(rbpkg: &RbPkgBuild) -> Option<CookbookPackage> {
let description = non_empty(&rbpkg.package.description);
let version = Some(if rbpkg.package.release > 0 {
format!("{}-{}", rbpkg.package.version, rbpkg.package.release)
} else {
rbpkg.package.version.clone()
});
if rbpkg.dependencies.runtime.is_empty() && description.is_none() && version.is_none() {
None
} else {
Some(CookbookPackage {
dependencies: rbpkg.dependencies.runtime.clone(),
version,
description,
})
}
}
fn custom_script(rbpkg: &RbPkgBuild) -> Result<String, CubError> {
let mut parts = Vec::new();
parts.extend(rbpkg.build.prepare.iter().cloned());
parts.extend(rbpkg.build.build_script.iter().cloned());
if rbpkg.policy.allow_tests {
parts.extend(rbpkg.build.check.iter().cloned());
}
parts.extend(rbpkg.build.install_script.iter().cloned());
if parts.is_empty() {
return Err(CubError::InvalidPkgbuild(
"custom template requires at least one prepare/build/check/install command".to_string(),
));
}
Ok(parts.join("\n"))
}
fn template_name(template: &BuildTemplate) -> &'static str {
match template {
BuildTemplate::Custom => "custom",
BuildTemplate::Cargo => "cargo",
BuildTemplate::Configure => "configure",
BuildTemplate::Cmake => "cmake",
BuildTemplate::Meson => "meson",
}
}
fn non_empty(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rbpkgbuild::{
BuildSection, BuildTemplate, CompatSection, ConversionStatus, DependenciesSection,
InstallSection, PackageSection, PatchesSection, PolicySection, RbPkgBuild, SourceEntry,
SourceSection, SourceType,
};
fn base_pkg(template: BuildTemplate) -> RbPkgBuild {
RbPkgBuild {
format: 1,
package: PackageSection {
name: "demo".to_string(),
version: "1.0.0".to_string(),
release: 1,
description: "demo package".to_string(),
homepage: String::new(),
license: vec!["MIT".to_string()],
architectures: vec!["x86_64-unknown-redox".to_string()],
maintainers: Vec::new(),
},
source: SourceSection {
sources: vec![SourceEntry {
source_type: SourceType::Git,
url: "https://example.com/repo.git".to_string(),
sha256: String::new(),
rev: "abc123".to_string(),
branch: "main".to_string(),
}],
},
dependencies: DependenciesSection {
build: vec!["cargo".to_string()],
runtime: vec!["openssl3".to_string()],
check: vec!["python".to_string()],
optional: Vec::new(),
provides: Vec::new(),
conflicts: Vec::new(),
},
build: BuildSection {
template,
release: true,
features: vec!["cli".to_string(), "full".to_string()],
args: vec!["--locked".to_string()],
build_dir: String::new(),
prepare: vec!["./autogen.sh".to_string()],
build_script: vec!["make".to_string()],
check: vec!["make test".to_string()],
install_script: vec!["make install DESTDIR=\"${COOKBOOK_STAGE}\"".to_string()],
},
install: InstallSection::default(),
patches: PatchesSection {
files: vec!["redox.patch".to_string()],
},
compat: CompatSection {
imported_from: String::new(),
original_pkgbuild: String::new(),
conversion_status: ConversionStatus::Full,
target: String::new(),
},
policy: PolicySection::default(),
}
}
#[test]
fn generates_cargo_recipe() {
let recipe = generate_recipe(&base_pkg(BuildTemplate::Cargo)).expect("generate recipe");
let value: toml::Value = toml::from_str(&recipe).expect("parse generated recipe");
assert_eq!(
value["source"]["git"].as_str(),
Some("https://example.com/repo.git")
);
assert_eq!(value["build"]["template"].as_str(), Some("cargo"));
assert_eq!(value["build"]["dependencies"][0].as_str(), Some("cargo"));
assert_eq!(value["source"]["patches"][0].as_str(), Some("redox.patch"));
assert_eq!(
value["package"]["dependencies"][0].as_str(),
Some("openssl3")
);
}
#[test]
fn generates_tar_recipe_with_checksum() {
let mut pkg = base_pkg(BuildTemplate::Cargo);
pkg.source.sources[0] = SourceEntry {
source_type: SourceType::Tar,
url: "https://example.com/demo.tar.gz".to_string(),
sha256: "abc123deadbeef".to_string(),
rev: String::new(),
branch: String::new(),
};
let recipe = generate_recipe(&pkg).expect("generate recipe");
let value: toml::Value = toml::from_str(&recipe).expect("parse generated recipe");
assert_eq!(
value["source"]["tar"].as_str(),
Some("https://example.com/demo.tar.gz")
);
assert_eq!(value["source"]["blake3"].as_str(), Some("abc123deadbeef"));
}
#[test]
fn generates_custom_script() {
let recipe = generate_recipe(&base_pkg(BuildTemplate::Custom)).expect("generate recipe");
let value: toml::Value = toml::from_str(&recipe).expect("parse generated recipe");
let script = value["build"]["script"].as_str().expect("custom script");
assert!(script.contains("./autogen.sh"));
assert!(
script.contains("make\n") || script.ends_with("make") || script.contains("make test")
);
assert!(script.contains("make install"));
}
#[test]
fn omits_test_commands_when_policy_disallows_them() {
let mut pkg = base_pkg(BuildTemplate::Custom);
pkg.policy.allow_tests = false;
let recipe = generate_recipe(&pkg).expect("generate recipe");
assert!(!recipe.contains("make test"));
}
}