diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..3de306a
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,50 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+env:
+ CARGO_TERM_COLOR: always
+
+jobs:
+ check:
+ name: Check
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+ - run: cargo check --workspace --all-targets
+
+ test:
+ name: Test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+ - run: cargo test --workspace
+
+ clippy:
+ name: Clippy
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ components: clippy
+ - uses: Swatinem/rust-cache@v2
+ - run: cargo clippy --workspace --all-targets -- -D warnings
+
+ fmt:
+ name: Format
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ components: rustfmt
+ - run: cargo fmt --all -- --check
diff --git a/README.md b/README.md
index 5ff5a84..dc9ff3d 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,7 @@
+
@@ -31,6 +32,7 @@
Package Format
Security
Repository Hosting
+ Relationship with xpkg
Roadmap
License
Command Cheatsheet
@@ -39,7 +41,7 @@
Overview
-xpm is a native Rust replacement for pacman and libalpm, designed for the X distribution. It uses the .xp package format (X Package) natively and maintains compatibility with Arch Linux .pkg.tar.zst packages.
+xpm is a native Rust replacement for pacman and libalpm, designed for the X distribution. It uses the .xp package format (X Package) natively and maintains compatibility with Arch Linux .pkg.tar.zst packages. Packages are built with xpkg , the companion builder tool.
Key Features
@@ -388,7 +390,7 @@ sudo xpm install xpkg
.PKGINFO - package name, version, dependencies
.BUILDINFO - reproducible build environment
.MTREE - file integrity hashes
- .INSTALL - optional pre/post install scripts
+ .INSTALL - optional pre/post install, upgrade, and remove scripts (executed by xpm during transactions)
Security
@@ -404,6 +406,36 @@ sudo xpm install xpkg
The default package repository is hosted on GitHub Pages at xscriptor.github.io/x-repo. This will migrate to the xscriptor organization for consistency as the project grows. xpm supports any HTTP-based static file server, making future migration to a VPS transparent.
+Relationship with xpkg
+
+xpm and xpkg are complementary tools in the X ecosystem. They share the same package format and metadata structures but are independent binaries.
+
+
+
+
+ Tool
+ Role
+ Analogy
+
+
+
+
+ xpm
+ Package manager — install, remove, upgrade, resolve deps
+ pacman
+
+
+ xpkg
+ Package builder — compile, package, lint, manage repos
+ makepkg + repo-add + namcap
+
+
+
+
+xpkg produces .xp packages that xpm installs. During installation, xpm executes any .INSTALL scriptlets generated by xpkg (post_install, pre_upgrade, etc.) and verifies signatures created during the build process.
+
+See the Integration Guide for detailed information about how the tools work together.
+
Roadmap
See ROADMAP.md for the full development roadmap.
diff --git a/crates/xpm-core/src/hooks.rs b/crates/xpm-core/src/hooks.rs
index ed9a928..7970cb1 100644
--- a/crates/xpm-core/src/hooks.rs
+++ b/crates/xpm-core/src/hooks.rs
@@ -78,9 +78,10 @@ impl Hook for FileExtractionHook {
// Check magic bytes
if n >= 4 && magic[..4] == [0x28, 0xB5, 0x2F, 0xFD] {
// Zstd
- Box::new(Decoder::new(buf).map_err(|e| {
- XpmError::Package(format!("failed to decode zstd: {}", e))
- })?)
+ Box::new(
+ Decoder::new(buf)
+ .map_err(|e| XpmError::Package(format!("failed to decode zstd: {}", e)))?,
+ )
} else if n >= 2 && magic[..2] == [0x1F, 0x8B] {
// Gzip
Box::new(GzDecoder::new(buf))
@@ -166,12 +167,43 @@ impl Hook for FileExtractionHook {
}
}
-/// Execute native package scriptlets from `.INSTALL` files.
-pub struct ScriptletHook;
+/// Execute pre-operation package scriptlets from `.INSTALL` files.
+///
+/// Runs before file changes: `pre_install` (install), `pre_upgrade` (upgrade), `pre_remove` (remove).
+pub struct PreScriptletHook;
+
+impl Hook for PreScriptletHook {
+ fn name(&self) -> &str {
+ "pre-scriptlet"
+ }
+
+ fn run(&self, context: &HookContext) -> XpmResult<()> {
+ let Some(script_data) = load_install_script(context)? else {
+ return Ok(());
+ };
+
+ let functions: &[&str] = match context.operation_type {
+ OperationType::Install => &["pre_install"],
+ OperationType::Upgrade => &["pre_upgrade"],
+ OperationType::Remove => &["pre_remove"],
+ };
+
+ if functions.is_empty() {
+ return Ok(());
+ }
+
+ run_scriptlet_functions(context, &script_data, functions)
+ }
+}
+
+/// Execute post-operation package scriptlets from `.INSTALL` files.
+///
+/// Runs after file changes: `post_install` (install), `post_upgrade` (upgrade), `post_remove` (remove).
+pub struct PostScriptletHook;
-impl Hook for ScriptletHook {
+impl Hook for PostScriptletHook {
fn name(&self) -> &str {
- "scriptlet"
+ "post-scriptlet"
}
fn run(&self, context: &HookContext) -> XpmResult<()> {
@@ -181,8 +213,8 @@ impl Hook for ScriptletHook {
let functions: &[&str] = match context.operation_type {
OperationType::Install => &["post_install"],
- OperationType::Upgrade => &["post_upgrade", "post_install"],
- OperationType::Remove => &[],
+ OperationType::Upgrade => &["post_upgrade"],
+ OperationType::Remove => &["post_remove"],
};
if functions.is_empty() {
@@ -553,11 +585,12 @@ impl HookChain {
impl Default for HookChain {
fn default() -> Self {
let mut chain = HookChain::new();
- // Add default hooks in order
+ // Hook order: pre-scriptlet → file extraction → file removal → post-scriptlet → local db
chain.add_hook(Box::new(MetadataLoadHook));
+ chain.add_hook(Box::new(PreScriptletHook));
chain.add_hook(Box::new(FileExtractionHook));
- chain.add_hook(Box::new(ScriptletHook));
chain.add_hook(Box::new(FileRemovalHook));
+ chain.add_hook(Box::new(PostScriptletHook));
chain.add_hook(Box::new(LocalDbHook));
chain
}
@@ -589,13 +622,13 @@ mod tests {
fn hook_chain_default_has_hooks() {
let chain = HookChain::default();
assert!(!chain.hooks().is_empty());
- assert_eq!(chain.hooks().len(), 5); // metadata, extraction, scriptlet, removal, localdb
+ assert_eq!(chain.hooks().len(), 6); // metadata, pre-scriptlet, extraction, removal, post-scriptlet, localdb
}
#[test]
- fn scriptlet_hook_runs_post_install() {
+ fn post_scriptlet_hook_runs_post_install() {
let (root, db_tmp, mut ctx) = test_context(OperationType::Install);
- let hook = ScriptletHook;
+ let hook = PostScriptletHook;
ctx.pkg_file = None;
let pkg_dir = db_tmp.path().join(&ctx.pkg_name);
diff --git a/crates/xpm-core/src/lib.rs b/crates/xpm-core/src/lib.rs
index 568a4cf..8a7b490 100644
--- a/crates/xpm-core/src/lib.rs
+++ b/crates/xpm-core/src/lib.rs
@@ -17,5 +17,5 @@ pub mod transaction;
// Re-export key types for convenience.
pub use config::XpmConfig;
pub use error::{XpmError, XpmResult};
-pub use transaction::{Transaction, TransactionOp, TransactionState, FileLock};
-pub use hooks::{Hook, HookChain, HookContext, OperationType};
+pub use hooks::{Hook, HookChain, HookContext, OperationType, PostScriptletHook, PreScriptletHook};
+pub use transaction::{FileLock, Transaction, TransactionOp, TransactionState};
diff --git a/crates/xpm-core/src/repo_db.rs b/crates/xpm-core/src/repo_db.rs
index 891ee5a..7868c4a 100644
--- a/crates/xpm-core/src/repo_db.rs
+++ b/crates/xpm-core/src/repo_db.rs
@@ -294,10 +294,7 @@ mod tests {
assert_eq!(hello.version, "1.0-1");
assert_eq!(hello.description.as_deref(), Some("hello package"));
assert_eq!(hello.arch.as_deref(), Some("x86_64"));
- assert_eq!(
- hello.filename.as_deref(),
- Some("hello-1.0-1-x86_64.xp")
- );
+ assert_eq!(hello.filename.as_deref(), Some("hello-1.0-1-x86_64.xp"));
assert_eq!(hello.sha256sum.as_deref(), Some("abc123"));
assert_eq!(
hello.url.as_deref(),
@@ -382,7 +379,10 @@ mod tests {
let linux = &db.entries[0];
assert_eq!(linux.name, "linux");
assert_eq!(linux.version, "6.1.0-1");
- assert_eq!(linux.description.as_deref(), Some("The Linux kernel and modules"));
+ assert_eq!(
+ linux.description.as_deref(),
+ Some("The Linux kernel and modules")
+ );
assert_eq!(linux.arch.as_deref(), Some("x86_64"));
// These fields should be None for standard Arch .db
assert!(linux.filename.is_none());
@@ -417,10 +417,7 @@ mod tests {
assert_eq!(xpkg.name, "xpkg");
assert_eq!(xpkg.version, "0.1.0-1");
// Extended fields should be present
- assert_eq!(
- xpkg.filename.as_deref(),
- Some("xpkg-0.1.0-1-x86_64.xp")
- );
+ assert_eq!(xpkg.filename.as_deref(), Some("xpkg-0.1.0-1-x86_64.xp"));
assert_eq!(
xpkg.sha256sum.as_deref(),
Some("deadbeefcafebabe0000000000000000deadbeefcafebabe0000000000000000")
@@ -465,10 +462,7 @@ mod tests {
fn parse_minimal_valid_db() {
// Minimum required fields: NAME, VERSION.
// This validates parser robustness for minimal entries.
- let bytes = make_gzip_tar(&[(
- "tiny-1.0-1/desc",
- "%NAME%\ntiny\n\n%VERSION%\n1.0-1\n",
- )]);
+ let bytes = make_gzip_tar(&[("tiny-1.0-1/desc", "%NAME%\ntiny\n\n%VERSION%\n1.0-1\n")]);
let db = parse_sync_db_bytes(&bytes, "test").expect("parse minimal db");
assert_eq!(db.entries.len(), 1);
@@ -497,7 +491,10 @@ mod tests {
entries.push((format!("{}/desc", version), desc));
}
- let entry_refs: Vec<_> = entries.iter().map(|(p, c)| (p.as_str(), c.as_str())).collect();
+ let entry_refs: Vec<_> = entries
+ .iter()
+ .map(|(p, c)| (p.as_str(), c.as_str()))
+ .collect();
let bytes = make_gzip_tar(&entry_refs);
let db = parse_sync_db_bytes(&bytes, "large").expect("parse large repo");
@@ -507,16 +504,13 @@ mod tests {
assert_eq!(names.len(), 50, "All package names should be unique");
}
-
#[test]
fn parse_db_with_optional_arch_field() {
// Some packages may omit ARCH; should default or be None.
- let bytes = make_gzip_tar(&[
- (
- "noarch-1.0-1/desc",
- "%NAME%\nnoarch\n\n%VERSION%\n1.0-1\n\n%DESC%\nNo architecture specified\n",
- ),
- ]);
+ let bytes = make_gzip_tar(&[(
+ "noarch-1.0-1/desc",
+ "%NAME%\nnoarch\n\n%VERSION%\n1.0-1\n\n%DESC%\nNo architecture specified\n",
+ )]);
let db = parse_sync_db_bytes(&bytes, "extra").expect("parse db without arch");
assert_eq!(db.entries[0].arch, None); // Our parser doesn't force a default
@@ -537,10 +531,7 @@ mod tests {
]);
let files_bytes = make_gzip_tar(&[
- (
- "bin-1.0-1/files",
- "%FILES%\nusr/bin/\nusr/bin/tool\n",
- ),
+ ("bin-1.0-1/files", "%FILES%\nusr/bin/\nusr/bin/tool\n"),
(
"lib-1.0-1/files",
"%FILES%\nusr/lib/\nusr/lib64/\nusr/lib/libfoo.so\n",
@@ -561,20 +552,25 @@ mod tests {
#[test]
fn real_db_file_from_disk() {
// Test parsing a real .db file if it exists in the workspace.
- let real_db_path = Path::new("/home/xscriptor/Documents/repos/xpkgrepos/x-repo/public/repo/x86_64/x.db.tar.gz");
-
+ let real_db_path = Path::new(
+ "/home/xscriptor/Documents/repos/xpkgrepos/x-repo/public/repo/x86_64/x.db.tar.gz",
+ );
+
if real_db_path.exists() {
let bytes = fs::read(real_db_path).expect("read real x.db");
let db = parse_sync_db_bytes(&bytes, "x").expect("parse real x.db");
-
+
// Validate structure
- assert!(!db.entries.is_empty(), "x.db should contain at least one entry");
+ assert!(
+ !db.entries.is_empty(),
+ "x.db should contain at least one entry"
+ );
for entry in &db.entries {
// Every entry must have name and version
assert!(!entry.name.is_empty(), "entry name must not be empty");
assert!(!entry.version.is_empty(), "entry version must not be empty");
}
-
+
// Check for expected xfetch entry if present
if let Some(xfetch) = db.entries.iter().find(|e| e.name == "xfetch") {
assert_eq!(xfetch.version, "0.1.0-1");
@@ -589,10 +585,7 @@ mod tests {
fn parse_db_with_version_variants() {
// Test parsing versions in different formats (semantic, pre-release, etc).
let bytes = make_gzip_tar(&[
- (
- "pkg1-1.0.0-1/desc",
- "%NAME%\npkg1\n\n%VERSION%\n1.0.0-1\n",
- ),
+ ("pkg1-1.0.0-1/desc", "%NAME%\npkg1\n\n%VERSION%\n1.0.0-1\n"),
(
"pkg2-2.5beta-2/desc",
"%NAME%\npkg2\n\n%VERSION%\n2.5beta-2\n",
diff --git a/crates/xpm-core/src/repo_sync.rs b/crates/xpm-core/src/repo_sync.rs
index c652bff..811728a 100644
--- a/crates/xpm-core/src/repo_sync.rs
+++ b/crates/xpm-core/src/repo_sync.rs
@@ -196,7 +196,11 @@ fn signature_cache_path(artifact_path: &Path) -> PathBuf {
/// 2) If `%URL%` is a direct `.xp` URL, use it as-is
/// 3) If `%URL%` points to a GitHub repo, derive release URL:
/// `/releases/download/-/`
-pub fn package_download_candidates(repo: &Repository, arch: &str, entry: &RepoEntry) -> Vec {
+pub fn package_download_candidates(
+ repo: &Repository,
+ arch: &str,
+ entry: &RepoEntry,
+) -> Vec {
let mut candidates = Vec::new();
let Some(filename) = entry.filename.as_deref() else {
@@ -216,9 +220,7 @@ pub fn package_download_candidates(repo: &Repository, arch: &str, entry: &RepoEn
} else if clean.starts_with("https://github.com/") {
let repo_url = clean.trim_end_matches('/');
let tag = format!("{}-{}", entry.name, entry.version);
- candidates.push(format!(
- "{repo_url}/releases/download/{tag}/{filename}"
- ));
+ candidates.push(format!("{repo_url}/releases/download/{tag}/{filename}"));
}
}
@@ -236,9 +238,7 @@ pub fn download_first_available(urls: &[String], dest: &Path, retries: u32) -> X
}
}
- Err(last_error.unwrap_or_else(|| {
- XpmError::Database("no usable package URL found".to_string())
- }))
+ Err(last_error.unwrap_or_else(|| XpmError::Database("no usable package URL found".to_string())))
}
/// Verify SHA-256 for a file if checksum metadata is available.
@@ -323,12 +323,13 @@ fn download_once(url: &str, dest: &Path) -> XpmResult<()> {
downloaded += n as u64;
if let Some(total) = total {
- if total > 0 {
- let percent = downloaded.saturating_mul(100) / total;
- if percent >= next_report {
- tracing::debug!(url, downloaded, total, percent, "download progress");
- next_report = (next_report + 25).min(100);
- }
+ let percent = total
+ .checked_div(1)
+ .map(|_| downloaded.saturating_mul(100) / total)
+ .unwrap_or(0);
+ if percent >= next_report {
+ tracing::debug!(url, downloaded, total, percent, "download progress");
+ next_report = (next_report + 25).min(100);
}
}
}
@@ -399,15 +400,8 @@ mod tests {
let keyring = temp.path().join("trustedkeys.gpg");
std::fs::write(&keyring, b"not-a-real-keyring").expect("write keyring placeholder");
- let result = sync_repo_databases(
- &repo,
- "x86_64",
- &sync,
- 2,
- SigLevel::Never,
- &keyring,
- )
- .expect("sync repo");
+ let result = sync_repo_databases(&repo, "x86_64", &sync, 2, SigLevel::Never, &keyring)
+ .expect("sync repo");
assert!(result.db_downloaded);
assert!(result.files_downloaded);
assert!(sync.join("core.db").exists());
@@ -479,7 +473,8 @@ mod tests {
let keyring = temp.path().join("trustedkeys.gpg");
let mut keyring_file = fs::File::create(&keyring).expect("create keyring");
- cert.serialize(&mut keyring_file).expect("write keyring cert");
+ cert.serialize(&mut keyring_file)
+ .expect("write keyring cert");
let repo = Repository {
name: "core".to_string(),
@@ -518,7 +513,8 @@ mod tests {
let keyring = temp.path().join("trustedkeys.gpg");
let mut keyring_file = fs::File::create(&keyring).expect("create keyring");
- cert.serialize(&mut keyring_file).expect("write keyring cert");
+ cert.serialize(&mut keyring_file)
+ .expect("write keyring cert");
let sig_url = format!("file://{}", sig_path.display());
let err = verify_remote_signature(&package_dest, &sig_url, SigLevel::Required, &keyring, 2)
diff --git a/crates/xpm-core/src/signing.rs b/crates/xpm-core/src/signing.rs
index 6ab71f9..c398dd0 100644
--- a/crates/xpm-core/src/signing.rs
+++ b/crates/xpm-core/src/signing.rs
@@ -21,15 +21,12 @@ pub fn load_keyring(path: &Path) -> XpmResult> {
let data = std::fs::read(path)
.map_err(|e| XpmError::SignatureError(format!("read keyring {}: {e}", path.display())))?;
- let parser = CertParser::from_bytes(&data).map_err(|e| {
- XpmError::SignatureError(format!("parse keyring {}: {e}", path.display()))
- })?;
+ let parser = CertParser::from_bytes(&data)
+ .map_err(|e| XpmError::SignatureError(format!("parse keyring {}: {e}", path.display())))?;
let mut certs = Vec::new();
for cert in parser {
- certs.push(
- cert.map_err(|e| XpmError::SignatureError(format!("keyring entry: {e}")))?,
- );
+ certs.push(cert.map_err(|e| XpmError::SignatureError(format!("keyring entry: {e}")))?);
}
Ok(certs)
@@ -119,4 +116,4 @@ impl VerificationHelper for VHelper {
None => Err(anyhow::anyhow!("no verification result")),
}
}
-}
\ No newline at end of file
+}
diff --git a/crates/xpm-core/src/transaction.rs b/crates/xpm-core/src/transaction.rs
index b59bfa5..5c38d1e 100644
--- a/crates/xpm-core/src/transaction.rs
+++ b/crates/xpm-core/src/transaction.rs
@@ -57,7 +57,7 @@ pub enum TransactionOp {
pkg_name: String,
pkg_version: String,
pkg_file: PathBuf,
- metadata: Option,
+ metadata: Box>,
},
/// Remove an installed package.
Remove { pkg_name: String },
@@ -148,7 +148,7 @@ impl Transaction {
pkg_name,
pkg_version,
pkg_file,
- metadata: None,
+ metadata: Box::new(None),
});
Ok(())
@@ -323,7 +323,12 @@ impl Transaction {
}
/// Upgrade a single package (internal - called by commit).
- fn execute_upgrade(&self, pkg_name: &str, new_version: &str, new_pkg_file: &Path) -> XpmResult<()> {
+ fn execute_upgrade(
+ &self,
+ pkg_name: &str,
+ new_version: &str,
+ new_pkg_file: &Path,
+ ) -> XpmResult<()> {
// Remove old version
self.execute_remove(pkg_name)?;
@@ -461,7 +466,10 @@ mod tests {
assert!(log_path.exists(), "log file should exist");
let content = fs::read_to_string(&log_path).expect("read log");
- assert!(content.contains("test message"), "log should contain message");
+ assert!(
+ content.contains("test message"),
+ "log should contain message"
+ );
}
#[test]
@@ -569,20 +577,15 @@ mod tests {
let pkg_file = make_test_xp_package(&cache_dir);
// Create transaction
- let mut tx = Transaction::new(root.clone(), local_db.clone())
- .expect("create transaction");
+ let mut tx = Transaction::new(root.clone(), local_db.clone()).expect("create transaction");
// Setup hooks
let hooks = HookChain::default();
tx.set_hooks(hooks);
// Add install operation
- tx.add_install(
- "test".to_string(),
- "1.0.0-1".to_string(),
- pkg_file.clone(),
- )
- .expect("add install");
+ tx.add_install("test".to_string(), "1.0.0-1".to_string(), pkg_file.clone())
+ .expect("add install");
// Prepare transaction
tx.prepare().expect("prepare");
@@ -616,17 +619,12 @@ mod tests {
// First install a package
let pkg_file = make_test_xp_package(&cache_dir);
- let mut tx = Transaction::new(root.clone(), local_db.clone())
- .expect("create transaction");
+ let mut tx = Transaction::new(root.clone(), local_db.clone()).expect("create transaction");
let hooks = HookChain::default();
tx.set_hooks(hooks);
- tx.add_install(
- "test".to_string(),
- "1.0.0-1".to_string(),
- pkg_file,
- )
- .expect("add install");
+ tx.add_install("test".to_string(), "1.0.0-1".to_string(), pkg_file)
+ .expect("add install");
tx.prepare().expect("prepare");
tx.commit().expect("commit");
@@ -636,12 +634,11 @@ mod tests {
assert!(test_file.exists(), "file should exist after install");
// Now remove the package (without hooks for now)
- let mut tx2 = Transaction::new(root.clone(), local_db.clone())
- .expect("create remove transaction");
+ let mut tx2 =
+ Transaction::new(root.clone(), local_db.clone()).expect("create remove transaction");
// Don't set hooks to isolate removal logic
- tx2.add_remove("test".to_string())
- .expect("add remove");
+ tx2.add_remove("test".to_string()).expect("add remove");
tx2.prepare().expect("prepare");
tx2.commit().expect("commit");
@@ -665,17 +662,12 @@ mod tests {
fs::copy(&pkg1, &pkg2_path).expect("copy package");
// Create transaction with multiple installs
- let mut tx = Transaction::new(root.clone(), local_db.clone())
- .expect("create transaction");
+ let mut tx = Transaction::new(root.clone(), local_db.clone()).expect("create transaction");
let hooks = HookChain::default();
tx.set_hooks(hooks);
- tx.add_install(
- "test".to_string(),
- "1.0.0-1".to_string(),
- pkg1,
- )
- .expect("add install 1");
+ tx.add_install("test".to_string(), "1.0.0-1".to_string(), pkg1)
+ .expect("add install 1");
// Prepare and commit first should succeed
tx.prepare().expect("prepare");
diff --git a/crates/xpm/src/main.rs b/crates/xpm/src/main.rs
index e30f4af..906d613 100644
--- a/crates/xpm/src/main.rs
+++ b/crates/xpm/src/main.rs
@@ -19,11 +19,11 @@ use cli::{Cli, Command};
use xpm_core::config::Repository;
use xpm_core::repo::RepoManager;
use xpm_core::repo_db::{merge_files_db, parse_sync_db};
-use xpm_core::resolver::Version;
use xpm_core::repo_sync::{
download_first_available, package_download_candidates, sync_repo_databases,
verify_remote_signature, verify_sha256,
};
+use xpm_core::resolver::Version;
use xpm_core::{HookChain, Transaction};
use xpm_core::{XpmConfig, XpmError};
@@ -182,7 +182,7 @@ fn cmd_sync(config: &XpmConfig, args: &cli::SyncArgs) -> Result<()> {
Ok(())
}
-fn display_rel_or_abs(path: &PathBuf) -> String {
+fn display_rel_or_abs(path: &Path) -> String {
std::env::current_dir()
.ok()
.and_then(|cwd| path.strip_prefix(cwd).ok().map(|p| p.display().to_string()))
@@ -294,15 +294,13 @@ fn cmd_install(config: &XpmConfig, args: &cli::InstallArgs, no_confirm: bool) ->
.with_context(|| format!("failed to create cache dir {}", cache_dir.display()))?;
// Create transaction
- let mut tx = Transaction::new(
- config.options.root_dir.clone(),
- local_db_dir,
- ).context("failed to create transaction")?;
+ let mut tx = Transaction::new(config.options.root_dir.clone(), local_db_dir)
+ .context("failed to create transaction")?;
// Setup hooks chain
let hooks = HookChain::default();
tx.set_hooks(hooks);
- tx.set_shell_integration(config.options.root_dir != PathBuf::from("/"));
+ tx.set_shell_integration(config.options.root_dir != Path::new("/"));
// Phase 1: Download and validate packages
for pkg_name in &args.packages {
@@ -349,12 +347,14 @@ fn cmd_install(config: &XpmConfig, args: &cli::InstallArgs, no_confirm: bool) ->
let sig_level = repo.sig_level.unwrap_or(config.options.sig_level);
let keyring_path = config.options.gpg_dir.join("trustedkeys.gpg");
let sig_url = format!("{mirror}.sig");
- verify_remote_signature(&dest, &sig_url, sig_level, &keyring_path, 3).with_context(|| {
- format!(
- "signature verification failed for '{}' from repo '{}'",
- pkg_name, repo.name
- )
- })?;
+ verify_remote_signature(&dest, &sig_url, sig_level, &keyring_path, 3).with_context(
+ || {
+ format!(
+ "signature verification failed for '{}' from repo '{}'",
+ pkg_name, repo.name
+ )
+ },
+ )?;
if let Some(sum) = entry.sha256sum.as_deref() {
verify_sha256(&dest, sum)?;
@@ -364,11 +364,8 @@ fn cmd_install(config: &XpmConfig, args: &cli::InstallArgs, no_confirm: bool) ->
println!(" source: {}", mirror);
// Add to transaction
- tx.add_install(
- entry.name.clone(),
- entry.version.clone(),
- dest,
- ).context("failed to add install to transaction")?;
+ tx.add_install(entry.name.clone(), entry.version.clone(), dest)
+ .context("failed to add install to transaction")?;
}
if args.download_only {
@@ -382,15 +379,21 @@ fn cmd_install(config: &XpmConfig, args: &cli::InstallArgs, no_confirm: bool) ->
)?;
// Phase 2: Prepare transaction (pre-flight checks)
- println!(":: Preparing transaction ({} operation(s))...", tx.operation_count());
+ println!(
+ ":: Preparing transaction ({} operation(s))...",
+ tx.operation_count()
+ );
tx.prepare().context("transaction preparation failed")?;
// Phase 3: Commit transaction (write changes)
println!(":: Committing transaction...");
tx.commit().context("transaction commit failed")?;
- println!(":: {} package(s) installed successfully.", args.packages.len());
- if config.options.root_dir != PathBuf::from("/") {
+ println!(
+ ":: {} package(s) installed successfully.",
+ args.packages.len()
+ );
+ if config.options.root_dir != Path::new("/") {
println!(":: Shell integration enabled via ~/.local/bin shims.");
println!(":: If this shell does not find new commands yet, run: hash -r");
println!(":: For immediate PATH refresh, run: source ~/.zshrc or source ~/.bashrc");
@@ -406,26 +409,22 @@ fn cmd_remove(config: &XpmConfig, args: &cli::RemoveArgs, no_confirm: bool) -> R
// Create transaction
let local_db_dir = config.options.db_path.join("local");
- let mut tx = Transaction::new(
- config.options.root_dir.clone(),
- local_db_dir.clone(),
- ).context("failed to create transaction")?;
+ let mut tx = Transaction::new(config.options.root_dir.clone(), local_db_dir.clone())
+ .context("failed to create transaction")?;
// Setup hooks chain
let hooks = HookChain::default();
tx.set_hooks(hooks);
- tx.set_shell_integration(config.options.root_dir != PathBuf::from("/"));
+ tx.set_shell_integration(config.options.root_dir != Path::new("/"));
// Add remove operations for each package
for pkg_name in &args.packages {
// Verify package is installed
let pkg_dir = local_db_dir.join(pkg_name);
if !pkg_dir.exists() {
- return Err(XpmError::Package(format!(
- "package '{}' is not installed",
- pkg_name
- ))
- .into());
+ return Err(
+ XpmError::Package(format!("package '{}' is not installed", pkg_name)).into(),
+ );
}
tx.add_remove(pkg_name.clone())
@@ -435,15 +434,21 @@ fn cmd_remove(config: &XpmConfig, args: &cli::RemoveArgs, no_confirm: bool) -> R
confirm_action(":: Proceed with removal? [y/N] ", no_confirm)?;
// Phase 2: Prepare transaction (pre-flight checks)
- println!(":: Preparing transaction ({} operation(s))...", tx.operation_count());
+ println!(
+ ":: Preparing transaction ({} operation(s))...",
+ tx.operation_count()
+ );
tx.prepare().context("transaction preparation failed")?;
// Phase 3: Commit transaction (write changes)
println!(":: Committing transaction...");
tx.commit().context("transaction commit failed")?;
- println!(":: {} package(s) removed successfully.", args.packages.len());
- if config.options.root_dir != PathBuf::from("/") {
+ println!(
+ ":: {} package(s) removed successfully.",
+ args.packages.len()
+ );
+ if config.options.root_dir != Path::new("/") {
println!(":: If command lookup is stale in current shell, run: hash -r");
}
Ok(())
@@ -490,11 +495,7 @@ fn cmd_upgrade(config: &XpmConfig, args: &cli::UpgradeArgs, no_confirm: bool) ->
let ordering = Version::cmp_versions(&entry.version, &local_version);
if ordering.is_gt() || (args.force && ordering.is_eq()) {
- planned.push((
- repo.clone(),
- entry.clone(),
- local_version,
- ));
+ planned.push((repo.clone(), entry.clone(), local_version));
}
}
@@ -514,7 +515,7 @@ fn cmd_upgrade(config: &XpmConfig, args: &cli::UpgradeArgs, no_confirm: bool) ->
.context("failed to create transaction")?;
let hooks = HookChain::default();
tx.set_hooks(hooks);
- tx.set_shell_integration(config.options.root_dir != PathBuf::from("/"));
+ tx.set_shell_integration(config.options.root_dir != Path::new("/"));
for (repo, entry, _) in planned {
let filename = entry.filename.clone().ok_or_else(|| {
@@ -536,12 +537,14 @@ fn cmd_upgrade(config: &XpmConfig, args: &cli::UpgradeArgs, no_confirm: bool) ->
let sig_level = repo.sig_level.unwrap_or(config.options.sig_level);
let keyring_path = config.options.gpg_dir.join("trustedkeys.gpg");
let sig_url = format!("{mirror}.sig");
- verify_remote_signature(&dest, &sig_url, sig_level, &keyring_path, 3).with_context(|| {
- format!(
- "signature verification failed for '{}' from repo '{}'",
- entry.name, repo.name
- )
- })?;
+ verify_remote_signature(&dest, &sig_url, sig_level, &keyring_path, 3).with_context(
+ || {
+ format!(
+ "signature verification failed for '{}' from repo '{}'",
+ entry.name, repo.name
+ )
+ },
+ )?;
if let Some(sum) = entry.sha256sum.as_deref() {
verify_sha256(&dest, sum)?;
@@ -553,7 +556,10 @@ fn cmd_upgrade(config: &XpmConfig, args: &cli::UpgradeArgs, no_confirm: bool) ->
.context("failed to add install op to transaction")?;
}
- println!(":: Preparing transaction ({} operation(s))...", tx.operation_count());
+ println!(
+ ":: Preparing transaction ({} operation(s))...",
+ tx.operation_count()
+ );
tx.prepare().context("transaction preparation failed")?;
println!(":: Committing transaction...");
@@ -629,9 +635,7 @@ fn read_latest_remote_entries(
}
for (name, entry) in best_by_name {
- latest
- .entry(name)
- .or_insert_with(|| (repo.clone(), entry));
+ latest.entry(name).or_insert_with(|| (repo.clone(), entry));
}
}
diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md
new file mode 100644
index 0000000..763a121
--- /dev/null
+++ b/docs/INTEGRATION.md
@@ -0,0 +1,119 @@
+# Integration Guide: xpm and xpkg
+
+This document describes how `xpm` and `xpkg` work together in the X ecosystem.
+
+## Tool Roles
+
+| Tool | Role | CLI binary | Config |
+|------|------|------------|--------|
+| **xpm** | Package manager | `/usr/bin/xpm` | `/etc/xpm.conf` |
+| **xpkg** | Package builder | `/usr/bin/xpkg` | `~/.config/xpkg/xpkg.conf` |
+
+`xpm` installs, removes, and manages packages on the target system. `xpkg` builds those packages from source recipes (XBUILD or PKGBUILD), lints them for quality, signs them, and manages repository databases.
+
+## Package Lifecycle
+
+```
+Source code → XBUILD recipe → xpkg build → .xp package → xpm install → System
+```
+
+1. A developer writes an XBUILD (TOML) or PKGBUILD recipe describing the package.
+2. `xpkg build` reads the recipe, downloads sources, compiles in an isolated environment (fakeroot), produces a `.xp` archive (tar.zst), and optionally signs it with OpenPGP.
+3. `xpkg repo-add` adds the package to a repository database.
+4. The repository (containing `.xp` + `.sig` + database files) is uploaded to a static HTTP server.
+5. On the target system, `xpm sync` downloads repository metadata.
+6. `xpm install ` or `xpm upgrade` downloads the `.xp` file, verifies the signature, extracts files, executes `.INSTALL` scriptlets, and registers the package in the local database.
+
+## Shared Package Format
+
+Both tools use the ALPM-compatible `.xp` format (`.tar.zst` archive):
+
+| Entry | Generated by xpkg | Used by xpm | Purpose |
+|-------|-------------------|-------------|---------|
+| `.PKGINFO` | Yes | Yes | Package name, version, dependencies, metadata |
+| `.BUILDINFO` | Yes | Yes | Reproducible build environment record |
+| `.MTREE` | Yes | Yes | File hierarchy with SHA-256 hashes for integrity |
+| `.INSTALL` | Yes | Yes | Optional shell scriptlets executed during install/remove/upgrade |
+
+## Scriptlet Execution
+
+When `xpm` processes a package that contains an `.INSTALL` file, it sources the scriptlets as bash functions:
+
+| Operation | Hooks called | Timing |
+|-----------|-------------|--------|
+| Install | `pre_install` | Before file extraction |
+| Install | `post_install` | After file extraction and database registration |
+| Upgrade | `pre_upgrade` | Before old version removal |
+| Upgrade | `post_upgrade` | After new version files are extracted |
+| Remove | `pre_remove` | Before file removal |
+| Remove | `post_remove` | After file removal and database cleanup |
+
+Scriptlets receive environment variables:
+
+- `XPM_ROOT_DIR` - the installation root directory
+- `XPM_PKG_NAME` - the package name
+- `XPM_PKG_VERSION` - the package version being processed
+
+## Signature Verification
+
+1. `xpkg sign` signs the `.xp` package and produces `.xp.sig` (detached OpenPGP signature).
+2. The public signing key is distributed via `trustedkeys.gpg` in the repository.
+3. `xpm` loads the keyring from `gpg_dir` (default `/etc/xpm/gnupg/`).
+4. During `sync` and `install`, `xpm` verifies detached signatures against the keyring.
+5. `sig_level` controls enforcement: `optional` (log warnings), `required` (fail on missing/invalid).
+
+## Repository Database
+
+`xpkg repo-add` creates and manages ALPM-compatible repository databases (`.db.tar.gz` + `.files.tar.gz`) with extended metadata:
+
+| Field | Standard ALPM | xpkg extension |
+|-------|--------------|----------------|
+| FILENAME | Yes | - |
+| SHA256SUM | - | Yes |
+| URL | - | Yes (source URL for rebuilds) |
+
+`xpm` parses both standard and extended fields, maintaining backward compatibility with Arch Linux repositories.
+
+## Dependency Resolution
+
+`xpkg` declares dependencies in the XBUILD recipe (`depends`, `makedepends`, `optdepends`, etc.) and writes them into `.PKGINFO`.
+
+`xpm` reads these declarations and uses its SAT-based resolver (`resolvo`) to compute a valid installation set, handling:
+
+- Version constraints (e.g. `glibc>=2.35`)
+- Conflicts (`conflicts` field)
+- Virtual providers (`provides` field)
+- Optional dependencies (`optdepends`)
+- Transitive dependencies
+
+## Examples
+
+### Build and install a package locally
+
+```bash
+# Developer machine
+xpkg build # produces hello-1.0.0-1-x86_64.xp
+scp hello-1.0.0-1-x86_64.xp user@target:/tmp
+
+# Target machine
+sudo xpm install /tmp/hello-1.0.0-1-x86_64.xp
+```
+
+### Build and publish to a repository
+
+```bash
+# Developer machine
+xpkg build
+xpkg repo-add /repo/x/x86_64/x.db.tar.gz hello-1.0.0-1-x86_64.xp
+# Upload /repo/ to HTTP server
+
+# Target machine
+sudo xpm sync
+sudo xpm install hello
+```
+
+## Version Alignment
+
+Both tools track the same package format and should be updated in lockstep when format changes occur. The format version is implicit in the `.PKGINFO` structure rather than an explicit version number.
+
+See each tool's ROADMAP for planned compatibility milestones.