use anyhow::{Context, Result, bail};
use clap::Subcommand;
use helix_toolbox_catalog::Catalog;
use helix_toolbox_core::printable;
use helix_toolbox_engine::{bundle, doctor, history};
use helix_toolbox_platform::LinuxInfo;
use std::{path::Path, path::PathBuf, process::Command};
#[derive(Subcommand)]
pub enum BundleCommand {
Create {
directory: PathBuf,
#[arg(required = true)]
ids: Vec<String>,
#[arg(long)]
complete: bool,
#[arg(long)]
dry_run: bool,
},
Verify { directory: PathBuf },
Install {
directory: PathBuf,
#[arg(long)]
trust_bundle: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
yes: bool,
},
}
fn curl(url: &str, to: &Path) -> bool {
Command::new("curl")
.args([
"--fail",
"--location",
"--silent",
"--show-error",
"--proto",
"=https,http",
"--output",
])
.arg(to)
.arg(url)
.status()
.is_ok_and(|status| status.success())
}
fn create_usb(refs: &[String], user: bool, bundle: &Path) -> bool {
let elevate = !user && !nix::unistd::geteuid().is_root();
let mut command = if elevate {
println!(
"flatpak create-usb updates the system installation's summary first, which needs root; sudo may ask for your password."
);
let mut command = Command::new("sudo");
command.arg("flatpak");
command
} else {
Command::new("flatpak")
};
command
.arg("create-usb")
.arg(if user { "--user" } else { "--system" })
.arg("--allow-partial")
.arg(format!("--destination-repo={}", bundle::FLATPAKS))
.arg(bundle)
.args(refs);
if !command.status().is_ok_and(|status| status.success()) {
return false;
}
!elevate
|| Command::new("sudo")
.arg("chown")
.arg("-R")
.arg(format!(
"{}:{}",
nix::unistd::getuid(),
nix::unistd::getgid()
))
.arg("--")
.arg(bundle.join(bundle::FLATPAKS))
.status()
.is_ok_and(|status| status.success())
}
fn flatpak_key(remote: &str, user: bool) -> PathBuf {
let variable = |name: &str| std::env::var_os(name).filter(|value| !value.is_empty());
let installation = if user {
variable("FLATPAK_USER_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| {
variable("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| {
variable("HOME").map(|home| PathBuf::from(home).join(".local/share"))
})
.unwrap_or_default()
.join("flatpak")
})
} else {
variable("FLATPAK_SYSTEM_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/var/lib/flatpak"))
};
installation.join(format!("repo/{remote}.trustedkeys.gpg"))
}
fn folder_bytes(path: &Path) -> u64 {
let Ok(listing) = std::fs::read_dir(path) else {
return 0;
};
listing
.flatten()
.map(|item| match item.file_type() {
Ok(kind) if kind.is_dir() => folder_bytes(&item.path()),
Ok(kind) if kind.is_file() => item.metadata().map(|data| data.len()).unwrap_or(0),
_ => 0,
})
.sum()
}
fn zypper_download(names: &[String], complete: bool, into: &Path) -> bool {
let Ok(scratch) = tempfile_dir() else {
return false;
};
let root = scratch.join("root");
let cache = scratch.join("packages");
let prepared = if complete {
std::fs::create_dir_all(&root).is_ok()
} else {
Command::new("rpm")
.args(["--eval", "%_dbpath"])
.output()
.ok()
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
.filter(|database| database.starts_with('/') && Path::new(database).is_dir())
.is_some_and(|database| {
let link = root.join(database.trim_start_matches('/'));
link.parent()
.is_some_and(|parent| std::fs::create_dir_all(parent).is_ok())
&& std::os::unix::fs::symlink(&database, &link).is_ok()
})
};
let downloaded = prepared
&& Command::new("zypper")
.arg("--non-interactive")
.arg("--installroot")
.arg(&root)
.arg("--pkg-cache-dir")
.arg(&cache)
.args(["install", "--download-only"])
.args(names)
.status()
.is_ok_and(|status| status.success());
let moved = downloaded && move_rpms(&cache, into);
let _ = std::fs::remove_dir_all(&scratch);
moved
}
fn move_rpms(from: &Path, into: &Path) -> bool {
let Ok(listing) = std::fs::read_dir(from) else {
return false;
};
listing.flatten().all(|item| {
let path = item.path();
if path.is_dir() {
move_rpms(&path, into)
} else if path.extension().is_some_and(|extension| extension == "rpm") {
std::fs::rename(&path, into.join(item.file_name())).is_ok()
|| std::fs::copy(&path, into.join(item.file_name())).is_ok()
} else {
true
}
})
}
fn dnf_download(names: &[String], complete: bool, into: &Path) -> bool {
let mut command = Command::new("dnf");
command.args(["download", "--resolve"]);
if complete {
command.arg("--alldeps");
}
command.arg("--destdir").arg(into).arg("--").args(names);
command.status().is_ok_and(|status| status.success())
}
pub fn run(
command: BundleCommand,
catalog: &Catalog,
platform: &LinuxInfo,
as_json: bool,
) -> Result<()> {
match command {
BundleCommand::Create {
directory,
ids,
complete,
dry_run,
} => {
if as_json && !dry_run {
bail!("--json shows bundles only with --dry-run");
}
for id in &ids {
crate::find(catalog, id)?;
}
let scratch = tempfile_dir()?;
let found =
bundle::contents(&ids, catalog, platform, complete, &doctor::Here, &scratch)?;
let _ = std::fs::remove_dir_all(&scratch);
if as_json {
crate::json::print(
"bundle",
serde_json::json!({
"entries": found.entries,
"downloads": found.downloads.iter().map(|download| serde_json::json!({
"url": download.url,
"file": download.file,
"checksum": download.checksum,
})).collect::<Vec<_>>(),
"dnf": found.dnf,
"zypper": found.zypper,
"flatpaks": found.flatpaks,
"complete": complete,
}),
);
return Ok(());
}
for entry in &found.entries {
match (&entry.network, &entry.flatpak) {
(None, Some(id)) => {
println!("Offline: {} (the Flatpak {id})", printable(&entry.name))
}
(None, None) => println!("Offline: {}", printable(&entry.name)),
(Some(why), _) => {
println!("Needs the network: {} ({why})", printable(&entry.name))
}
}
}
let files = found
.downloads
.iter()
.filter(|download| !download.file.ends_with(".sig"))
.count();
if !found.dnf.is_empty() {
println!(
"dnf downloads {} with their dependencies.",
found.dnf.join(", ")
);
} else if !found.zypper.is_empty() {
println!(
"zypper downloads {} with {}, as you, without root.",
found.zypper.join(", "),
if complete {
"every dependency"
} else {
"the dependencies this machine lacks"
}
);
} else if files > 0 || found.flatpaks.is_empty() {
println!(
"{files} package files to download{}.",
if complete {
", every dependency"
} else {
", with the dependencies this machine lacks"
}
);
}
if !found.flatpaks.is_empty() {
println!(
"flatpak create-usb copies {}, with the runtimes they need, from this machine's installation.",
found
.flatpaks
.iter()
.map(|flatpak| printable(&flatpak.reference))
.collect::<Vec<_>>()
.join(", ")
);
}
if dry_run {
for download in &found.downloads {
println!(" {}", printable(&download.url));
}
println!("Dry run: nothing was downloaded.");
return Ok(());
}
if !found.entries.iter().any(|entry| entry.offline) {
bail!("none of these entries can be bundled; they all need the network");
}
let tools = bundle::Tools {
system: &doctor::Here,
download: &curl,
dnf_download: &dnf_download,
zypper_download: &zypper_download,
create_usb: &create_usb,
flatpak_key: &flatpak_key,
};
let this =
std::env::current_exe().context("cannot find the Helix Toolbox executable")?;
let build = crate::inspect::build();
let manifest = bundle::create(
&directory,
&found,
platform,
complete,
Some((&this, crate::VERSION, build.release)),
history::now(),
&tools,
)?;
let bytes: u64 = manifest.files.iter().map(|file| file.bytes).sum::<u64>()
+ folder_bytes(&directory.join(bundle::FLATPAKS));
println!(
"Made {} ({} files, {:.1} MB) for {} {}.\nCopy the folder over; on the other machine, inside it: ./helix-toolbox bundle install .",
directory.display(),
manifest.files.len(),
bytes as f64 / 1e6,
manifest.distribution,
manifest.architecture
);
Ok(())
}
BundleCommand::Verify { directory } => {
let manifest = bundle::verify(&directory, platform)?;
if as_json {
crate::json::print(
"bundle",
serde_json::to_value(&manifest).unwrap_or_default(),
);
return Ok(());
}
println!(
"{}: made for {} {} with Helix Toolbox {}{}; {} files, every one matching its checksum.",
directory.display(),
printable(&manifest.distribution),
printable(&manifest.architecture),
printable(&manifest.helixforge),
if manifest.release {
" (a release build)"
} else {
""
},
manifest.files.len()
);
for flatpak in &manifest.flatpaks {
println!(
" Flatpak: {} at commit {}, from {}",
printable(&flatpak.reference),
&flatpak.commit[..12],
printable(&flatpak.remote)
);
}
for entry in &manifest.entries {
match &entry.network {
None => println!(" Offline: {}", printable(&entry.name)),
Some(why) => println!(
" Needs the network: {} ({})",
printable(&entry.name),
printable(why)
),
}
}
Ok(())
}
BundleCommand::Install {
directory,
trust_bundle,
dry_run,
yes,
} => {
let manifest = bundle::verify(&directory, platform)?;
println!(
"The bundle matches its checksums: {} files for {}.",
manifest.files.len(),
printable(&manifest.distribution)
);
let planned = bundle::plan_install(
&directory,
&manifest,
&[],
platform,
&doctor::Here,
trust_bundle,
)?;
crate::confirm_and_run(catalog, platform, &planned, dry_run, yes, "INSTALL")
}
}
}
fn tempfile_dir() -> Result<PathBuf> {
use std::os::unix::fs::DirBuilderExt;
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.subsec_nanos())
.unwrap_or(0);
let base = std::env::temp_dir().join(format!(
"helix-toolbox-bundle-{}-{nanos}",
std::process::id()
));
std::fs::DirBuilder::new()
.mode(0o700)
.create(&base)
.with_context(|| format!("cannot create {}", base.display()))?;
Ok(base)
}