use std::env; use std::io::Write; extern crate clap; use clap::{Command, arg, command}; use pkh::context::ContextConfig; extern crate flate2; use pkh::changelog::generate_entry; use indicatif_log_bridge::LogWrapper; use log::{error, info}; /// Obtain the current working directory, exiting with a helpful message on failure. fn current_dir_or_exit() -> std::path::PathBuf { match std::env::current_dir() { Ok(p) => p, Err(e) => { error!("Could not determine the current working directory: {}", e); std::process::exit(1); } } } fn main() { let rt = tokio::runtime::Runtime::new().unwrap(); let logger = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) .format_timestamp(None) .format(|buf, record| writeln!(buf, "{}", record.args())) .build(); let multi = indicatif::MultiProgress::new(); LogWrapper::new(multi.clone(), logger).try_init().unwrap(); let matches = command!() .subcommand_required(true) .disable_version_flag(true) .subcommand( Command::new("pull") .about("Pull a source package from the archive or git") .arg( arg!(-s --series "Target package distribution series").required(false), ) .arg( arg!(-d --dist "Target package distribution (debian, ubuntu)") .required(false), ) .arg(arg!(-v --version "Target package version").required(false)) .arg(arg!(--archive "Only use the archive to download package source, not git").required(false)) .arg(arg!(--ppa "Download the package from a specific PPA").required(false)) .arg(arg!(-p --pocket "Target package distribution pocket (updates, security, proposed)").required(false)) .arg(arg!( "Target package")), ) .subcommand( Command::new("chlog") .about("Auto-generate changelog entry, editing it, committing it afterwards") .arg(arg!(-s --series "Target distribution series").required(false)) .arg(arg!(--backport "This changelog is for a backport entry").required(false)) .arg(arg!(-v --version "Target version").required(false)), ) .subcommand(Command::new("build").about("Build the source package (into a .dsc)")) .subcommand( Command::new("deb") .about("Build the source package into binary package (.deb)") .arg(arg!(-s --series "Target distribution series").required(false)) .arg(arg!(-a --arch "Target architecture").required(false)) .arg(arg!(-p --pocket "Build against dependencies from a specific distribution pocket (updates, security, proposed)").required(false) .long_help("Build against dependencies from a specific distribution pocket (e.g. updates, security, proposed).\nThe '-' suite will be enabled on archive sources when resolving build-dependencies.")) .arg(arg!(--ppa "Build the package adding a specific PPA for dependencies (can be specified multiple times)") .long_help("Build the package adding a specific PPA for dependencies. Can be specified multiple times.").required(false).action(clap::ArgAction::Append)) .arg(arg!(--inject "Inject a package into the build environment (can be specified multiple times)") .long_help("Inject a package into the build environment before build-dep. Can be a .deb file path, a package name from the archive, or a package from a previously added PPA. Can be specified multiple times.").required(false).action(clap::ArgAction::Append)) .arg(arg!(--cross "Cross-compile for target architecture (instead of qemu-binfmt)") .long_help("Cross-compile for target architecture (instead of using qemu-binfmt)\nNote that most packages cannot be cross-compiled").required(false)) .arg(arg!(--mode "Change build mode [local]").required(false) .long_help("Change build mode [local]\nDefault will chose depending on other parameters, don't provide if unsure")) .arg(arg!(--verbose "Show raw tool output instead of the live build view").required(false) .long_help("Show raw tool output instead of the live build view.\nAlso implied by RUST_LOG=debug for pkh's own logs.")), ) .subcommand( Command::new("context") .about("Manage contexts") .subcommand_required(true) .subcommand( Command::new("create") .about("Create a new context") .arg(arg!( "Context name")) .arg(arg!(--type "Context type: ssh (only type supported for now)")) .arg(arg!(--endpoint "Context endpoint (for example: ssh://user@host:port)")) ) .subcommand( Command::new("rm") .about("Remove a context") .arg(arg!( "Context name")) ) .subcommand( Command::new("ls") .about("List contexts") ) .subcommand(Command::new("show").about("Show current context")) .subcommand( Command::new("use") .about("Set current context") .arg(arg!( "Context name")) ) ) .subcommand( Command::new("prune") .about("Prune residual pkh build artifacts and caches") // NOTE: --dry-run is defined via the builder API because clap's // `arg!` macro mis-tokenizes hyphenated long names (it would // parse `--dry-run` as long="dry" plus a spurious short flag, // tripping the "Short flags should precede long flags" assert). .arg( clap::Arg::new("dry_run") .long("dry-run") .action(clap::ArgAction::SetTrue) .help("List what would be removed without removing anything"), ) .arg( clap::Arg::new("all") .long("all") .action(clap::ArgAction::SetTrue) .help("Also remove cached chroot tarballs (expensive to re-download)"), ) ) .get_matches(); match matches.subcommand() { Some(("pull", sub_matches)) => { let package = sub_matches.get_one::("package").expect("required"); let series = sub_matches.get_one::("series").map(|s| s.as_str()); let dist = sub_matches.get_one::("dist").map(|s| s.as_str()); let version = sub_matches.get_one::("version").map(|s| s.as_str()); let ppa = sub_matches.get_one::("ppa").map(|s| s.as_str()); let pocket = sub_matches .get_one::("pocket") .map(|s| s.as_str()) .unwrap_or(""); let archive = sub_matches.get_one::("archive").unwrap_or(&false); let (pb, progress_callback) = pkh::ui::create_progress_bar(&multi); // Convert PPA to base URL if provided let base_url = ppa.and_then(|ppa_str| { // PPA format: user/ppa_name let parts: Vec<&str> = ppa_str.split('/').collect(); if parts.len() == 2 { Some(pkh::package_info::ppa_to_base_url(parts[0], parts[1])) } else { None } }); // Since pull is async, we need to block on it if let Err(e) = rt.block_on(async { let package_info = pkh::package_info::lookup( package, version, series, pocket, dist, base_url.as_deref(), Some(&progress_callback), ) .await?; pkh::pull::pull(&package_info, None, Some(&progress_callback), *archive).await }) { pb.finish_and_clear(); error!("{}", e); std::process::exit(1); } pb.finish_and_clear(); multi.remove(&pb); info!("Done."); } Some(("chlog", sub_matches)) => { let cwd = current_dir_or_exit(); let version = sub_matches.get_one::("version").map(|s| s.as_str()); let cli_series = sub_matches.get_one::("series").map(|s| s.as_str()); // Determine target series: CLI flag > interactive selector > current changelog series let target_series = if let Some(s) = cli_series { Some(s.to_string()) } else { // Parse current changelog to determine the default series let changelog_path = cwd.join("debian/changelog"); match pkh::changelog::parse_changelog_header(&changelog_path) { Ok((_pkg, _ver, current_series)) => { // Try to get the list of available series for this distribution match rt.block_on(async { let dist = pkh::distro_info::get_dist_from_series(¤t_series).await?; pkh::distro_info::get_ordered_series_name(&dist).await }) { Ok(series_list) => { match pkh::ui::select_series(&series_list, ¤t_series) { Ok(selected) => Some(selected), Err(e) => { error!( "Series selection failed: {}. Using current series '{}' instead.", e, current_series ); Some(current_series) } } } Err(_) => { // Could not fetch series list, use current series as default Some(current_series) } } } Err(_) => None, } }; if let Err(e) = generate_entry( "debian/changelog", Some(&cwd), version, target_series.as_deref(), ) { error!("{}", e); std::process::exit(1); } let editor = match std::env::var("EDITOR") { Ok(e) => e, Err(_) => { error!( "No editor configured. Set the EDITOR environment variable \ (e.g. `EDITOR=nano` or `export EDITOR=vim`) and retry." ); std::process::exit(1); } }; let _status = std::process::Command::new(&editor) .current_dir(&cwd) .args(["debian/changelog"]) .status() .map_err(|e| { error!( "Could not launch editor '{}': {}. \ Make sure it is installed and available on PATH.", editor, e ); std::process::exit(1); }); } Some(("build", _sub_matches)) => { let cwd = current_dir_or_exit(); if let Err(e) = pkh::build::build_source_package(Some(&cwd)) { error!("{}", e); // Unmet build dependencies/conflicts exit with status 3, // like dpkg-buildpackage does. if e.downcast_ref::() .is_some() { std::process::exit(3); } std::process::exit(1); } } Some(("deb", sub_matches)) => { let cwd = current_dir_or_exit(); let series = sub_matches.get_one::("series").map(|s| s.as_str()); let pocket = sub_matches.get_one::("pocket").map(|s| s.as_str()); let arch = sub_matches.get_one::("arch").map(|s| s.as_str()); let cross = sub_matches.get_one::("cross").unwrap_or(&false); let ppa: Vec<&str> = sub_matches .get_many::("ppa") .map(|v| v.map(|s| s.as_str()).collect()) .unwrap_or_default(); let ppa = if ppa.is_empty() { None } else { Some(ppa.as_slice()) }; let inject_packages: Vec<&str> = sub_matches .get_many::("inject") .map(|v| v.map(|s| s.as_str()).collect()) .unwrap_or_default(); let inject_packages = if inject_packages.is_empty() { None } else { Some(inject_packages.as_slice()) }; let mode: Option<&str> = sub_matches.get_one::("mode").map(|s| s.as_str()); let mode: Option = match mode { Some("local") => Some(pkh::deb::BuildMode::Local), _ => None, }; let verbose = sub_matches .get_one::("verbose") .copied() .unwrap_or(false); // Live build view: disabled by --verbose or when stdout is not a // terminal (DebUi handles the non-TTY case itself) let ui = if verbose { None } else { Some(std::sync::Arc::new(pkh::ui::deb::DebUi::new(&multi))) }; let result = rt.block_on(async { pkh::deb::build_binary_package( arch, series, pocket, Some(cwd.as_path()), *cross, mode, ppa, inject_packages, None, ui.clone(), ) .await }); match result { Ok(artifacts) => { let _ = artifacts; info!("Done."); } Err(e) => { error!("{}", e); std::process::exit(1); } } } Some(("context", sub_matches)) => { let mgr = pkh::context::manager(); match sub_matches.subcommand() { Some(("create", args)) => { let name = args.get_one::("name").unwrap(); let type_str = args .get_one::("type") .map(|s| s.as_str()) .unwrap_or("local"); let context = match type_str { "local" => ContextConfig::Local, "ssh" => { let endpoint = args.get_one::("endpoint").unwrap_or_else(|| { error!( "An --endpoint is required to create an ssh context. \ Expected format: [ssh://][user@]host[:port]" ); std::process::exit(1); }); // Parse host, user, port from endpoint // Formats: [ssh://][user@]host[:port] let endpoint_re = regex::Regex::new(r"^(?:ssh://)?(?:(?P[^@]+)@)?(?P[^:/]+)(?::(?P\d+))?$").unwrap(); let endpoint_cap = endpoint_re.captures(endpoint).unwrap_or_else(|| { error!("Invalid endpoint format: '{}'. Expected [ssh://][user@]host[:port]", endpoint); std::process::exit(1); }); let host = endpoint_cap.name("host").unwrap().as_str().to_string(); let user = endpoint_cap.name("user").map(|m| m.as_str().to_string()); let port = endpoint_cap.name("port").map(|m| { m.as_str().parse::().unwrap_or_else(|_| { error!("Invalid port number"); std::process::exit(1); }) }); ContextConfig::Ssh { host, user, port } } _ => { error!("Unknown context type: {}", type_str); std::process::exit(1); } }; if let Err(e) = mgr.add_context(name, context) { error!("Failed to create context: {}", e); std::process::exit(1); } info!("Context '{}' created.", name); } Some(("rm", args)) => { let name = args.get_one::("name").unwrap(); if let Err(e) = mgr.remove_context(name) { error!("Failed to remove context: {}", e); std::process::exit(1); } info!("Context '{}' removed.", name); } Some(("ls", _)) => { let contexts = mgr.list_contexts(); let current = mgr.current_name(); for ctx in contexts { if ctx == current { println!("* {}", ctx); } else { println!(" {}", ctx); } } } Some(("show", _)) => {} Some(("use", args)) => { let name = args.get_one::("name").unwrap(); if let Err(e) = mgr.set_current(name) { error!("Failed to set context: {}", e); std::process::exit(1); } info!("Switched to context '{}'.", name); } _ => unreachable!(), } } Some(("prune", sub_matches)) => { let dry_run = sub_matches .get_one::("dry_run") .copied() .unwrap_or(false); let all = sub_matches.get_one::("all").copied().unwrap_or(false); let options = pkh::prune::PruneOptions { dry_run, all }; match pkh::prune::prune(options) { Ok(report) => { if report.is_empty() { info!("Nothing to prune."); } else { let action = if report.dry_run { "Would remove" } else { "Removed" }; for path in &report.removed { info!("{} {}", action, path.display()); } for (path, err) in &report.failed { error!("Failed to remove {}: {}", path.display(), err); } if report.dry_run { info!( "(dry run) {} item(s) would be removed.", report.removed.len() ); } else { info!( "Pruned {} item(s){} ({} failure(s)).", report.removed.len(), if all { " including cached chroot tarballs" } else { "" }, report.failed.len() ); } } if !report.failed.is_empty() { std::process::exit(1); } } Err(e) => { error!("{}", e); std::process::exit(1); } } } _ => unreachable!("Exhausted list of subcommands and subcommand_required prevents `None`"), } }