use std::env; use std::io::Write; extern crate clap; use clap::{Command, arg, command}; extern crate flate2; 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); } } } /// CLI-side Ctrl+C wiring. The passive state (interrupted flag, cleanup /// hook registry, reporter slot) lives in `pkh::interrupt`; everything that /// installs, prints or exits lives here: the SIGINT handler only wakes a /// watchdog through a self-pipe (async-signal-safe), and the watchdog runs /// the whole shutdown in thread context — the live view's reporter clears /// the terminal, the notice is printed, further Ctrl+C is absorbed as a /// no-op, the cleanup hooks release their resources, and the process exits /// with the conventional status 130, skipping destructors. mod interrupt { use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; /// Whether the interrupt notice has been shown; the first caller prints /// it, later ones stay silent static NOTICE_SHOWN: AtomicBool = AtomicBool::new(false); /// Whether handler, self-pipe and watchdog are in place static INSTALLED: AtomicBool = AtomicBool::new(false); /// Write end of the self-pipe the signal handler wakes the watchdog /// through; `-1` until [`install`] set it up static SELF_PIPE_WRITE: AtomicI32 = AtomicI32::new(-1); /// Install the process-global Ctrl+C (SIGINT) handler; idempotent. /// /// When the self-pipe or the watchdog cannot be set up, the default /// SIGINT disposition is kept (the process dies immediately) rather /// than installing a handler that could not run the shutdown. pub fn install() { if INSTALLED.swap(true, Ordering::SeqCst) { return; } let mut fds = [0 as libc::c_int; 2]; // SAFETY: pipe(2) into a two-element array we own if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 { INSTALLED.store(false, Ordering::SeqCst); return; } let (read_fd, write_fd) = (fds[0], fds[1]); // The write end is used from the signal handler: non-blocking, so // even a full pipe degrades to a dropped wake-up instead of // blocking the handler. // SAFETY: fcntl(2) on a pipe file descriptor we just created unsafe { let flags = libc::fcntl(write_fd, libc::F_GETFL); libc::fcntl(write_fd, libc::F_SETFL, flags | libc::O_NONBLOCK); } SELF_PIPE_WRITE.store(write_fd, Ordering::SeqCst); let spawned = std::thread::Builder::new() .name("pkh-interrupt".to_string()) .spawn(move || watchdog(read_fd)); if spawned.is_err() { // SAFETY: closing pipe file descriptors we just created unsafe { libc::close(read_fd); libc::close(write_fd); } SELF_PIPE_WRITE.store(-1, Ordering::SeqCst); INSTALLED.store(false, Ordering::SeqCst); return; } // SAFETY: installing a signal handler whose body only records the // interruption and writes to the self-pipe (async-signal-safe) unsafe { libc::signal(libc::SIGINT, on_sigint as *const () as usize); } } /// Never returns: park the calling thread until the watchdog exits the /// process /// /// The watchdog owns the interrupt shutdown; a caller that would /// otherwise reach its own `std::process::exit` and kill the process /// mid-cleanup must park here instead. pub fn wait_for_shutdown() -> ! { loop { std::thread::sleep(std::time::Duration::from_millis(100)); } } /// Signal handler body: record the interruption and wake the watchdog /// through the self-pipe extern "C" fn on_sigint(_sig: libc::c_int) { pkh::interrupt::mark_interrupted(); let fd = SELF_PIPE_WRITE.load(Ordering::SeqCst); if fd >= 0 { // SAFETY: write(2) of one byte to the self-pipe is // async-signal-safe; a failed write (e.g. EAGAIN) drops the // wake-up instead of blocking the handler unsafe { libc::write(fd, b"x".as_ptr().cast(), 1); } } } /// Watchdog body: block until the signal handler's byte arrives, then /// run the shutdown sequence fn watchdog(read_fd: libc::c_int) { let mut byte = [0u8; 1]; // SAFETY: read(2) into a local buffer of the announced length let received = unsafe { libc::read(read_fd, byte.as_mut_ptr().cast(), 1) }; // The write end is never closed, so a short read cannot happen in // practice; on error there is nothing to clean up either way. if received > 0 { run_interrupt_sequence(); } } /// Reporter, notice, cleanup hooks, exit 130: the whole shutdown, run /// in the watchdog thread immediately on Ctrl+C — never in the signal /// handler itself /// /// Further Ctrl+C while this runs writes bytes nobody reads: absorbed /// as a no-op (send SIGTERM/SIGKILL if a hook ever hangs). fn run_interrupt_sequence() { let hint = pkh::interrupt::take_reporter().and_then(|report| { match std::panic::catch_unwind(std::panic::AssertUnwindSafe(report)) { Ok(hint) => hint, Err(_) => { log::error!("Interrupt reporter panicked"); None } } }); show_notice(); if let Some(hint) = hint { eprintln!("{hint}"); } pkh::interrupt::run_cleanup_hooks(); // SAFETY: raw exit bypassing destructors, intended at interrupt time unsafe { libc::_exit(130); } } /// Print the interrupt notice, once per process: the first caller /// prints it, later ones stay silent fn show_notice() { if NOTICE_SHOWN.swap(true, Ordering::SeqCst) { return; } eprintln!("CTRL+C: Build interrupted by user."); } } 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) .subcommand( Command::new("new") .about("Scaffold a new Debian source package (buildable right away)") .arg(arg!([name] "Package name: creates .// with a fresh project skeleton. Without it (or with --source), the given/current directory is packaged")) // NOTE: hyphenated long names are defined via the builder API // because clap's `arg!` macro mis-tokenizes them (see the // prune subcommand note below). .arg( clap::Arg::new("lang") .long("lang") .value_name("LANG") .help("Language/build system: rust, python, meson, cmake, autotools, go, shell, makefile or empty"), ) .arg( clap::Arg::new("source") .long("source") .value_name("PATH") .conflicts_with("name") .help("Package the sources in PATH instead of creating .//"), ) .arg( clap::Arg::new("upstream_version") .long("upstream-version") .value_name("VERSION") .help("Upstream version (default: 0.1.0)"), ) .arg( clap::Arg::new("revision") .long("revision") .value_name("N") .value_parser(clap::value_parser!(u32)) .help("Debian revision (default: 1)"), ) .arg( clap::Arg::new("description") .long("description") .value_name("DESC") .help("One-line package description"), ) .arg( clap::Arg::new("homepage") .long("homepage") .value_name("URL") .help("Upstream homepage (http:// or https://)"), ) .arg( clap::Arg::new("license") .long("license") .value_name("SPDX") .help("Upstream license (SPDX identifier, e.g. MIT, GPL-3.0+)"), ) .arg( clap::Arg::new("command") .long("command") .value_name("CMD") .help("Installed command name (default: the package name)"), ) .arg( clap::Arg::new("maintainer") .long("maintainer") .value_name("NAME ") .help("Maintainer (default: DEBFULLNAME/DEBEMAIL, then git config)"), ) .arg( clap::Arg::new("depends") .long("depends") .value_name("LIST") .action(clap::ArgAction::Append) .long_help("Runtime Depends of the metapackage flavor ('empty' template), as a comma-separated list (e.g. \"hello, hello-data (>= 1.0)\"). Can be specified multiple times.") .help("Metapackage Depends list, comma-separated ('empty' template only)"), ) .arg( clap::Arg::new("dist") .long("dist") .value_name("DIST") .help("Target distribution: debian or ubuntu (default: current vendor)"), ) .arg( clap::Arg::new("series") .long("series") .value_name("SERIES") .help("Target series (default: the development series of --dist)"), ) .arg(arg!(--release "Write the --series into debian/changelog instead of UNRELEASED").required(false)) .arg(arg!(--native "Use the 3.0 (native) source format (default for a new project skeleton: no orig tarball)").required(false)) .arg( clap::Arg::new("quilt") .long("quilt") .action(clap::ArgAction::SetTrue) .conflicts_with("native") .help("Use the 3.0 (quilt) source format with an orig tarball (default when packaging an existing project)"), ) .arg( clap::Arg::new("orig_from") .long("orig-from") .value_name("MODE") .value_parser(["release", "git", "path", "snapshot"]) .conflicts_with("native") .help("How to produce the orig tarball (quilt only): release (download the forge tarball of the tag; network), git (git archive of the tag), path (repack --orig-path), snapshot (tar the working tree). Default: git on a tag, snapshot otherwise"), ) .arg( clap::Arg::new("orig_path") .long("orig-path") .value_name("FILE|URL") .conflicts_with("native") .help("Tarball used by --orig-from path: a local file or an http(s) URL (.tar, .tar.gz, .tgz, .tar.bz2, .tbz2, .tar.xz), repacked to the orig"), ) .arg( clap::Arg::new("no_git") .long("no-git") .action(clap::ArgAction::SetTrue) .help("Do not initialize a git repository (.gitignore files are written anyway)"), ) .arg( clap::Arg::new("no_verify") .long("no-verify") .action(clap::ArgAction::SetTrue) .help("Skip the post-scaffold build verification (the structural self-checks always run)"), ) .arg(arg!(--defaults "Take the default answer for every question left unanswered (the package name is still required)").required(false)), ) .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 (format: user/ppa_name)").required(false)) .arg(arg!(--repository "Download the package from an external flat repository, given as its full suite URL (e.g. https://pkg.noctalia.dev/deb/resolute/)").required(false) .conflicts_with("ppa")) .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 "Number the entry as a backport of the target series (Debian: 1.0-1 becomes 1.0-1~bpo12+1; Ubuntu: 3.1-1ubuntu2 becomes 3.1-1ubuntu2~24.04.1)").required(false) .conflicts_with_all(["nmu", "rebuild"])) .arg(arg!(--nmu "Number the entry as a non-maintainer upload (1.0-1 becomes 1.0-1.1, native 1.0 becomes 1.0+nmu1)").required(false) .conflicts_with("rebuild")) .arg(arg!(--rebuild "Number the entry as a no-change rebuild (1.0-1 becomes 1.0-1build1)").required(false)) .arg(arg!(-v --version "Target version (overrides the --backport/--nmu/--rebuild numbering)").required(false)), ) .subcommand( Command::new("build") .about("Build the source package (into a .dsc)") .arg(arg!(--verbose "Show raw tool output instead of the live build view").required(false)) .arg(arg!(--orig "Original source tarball in the upload [auto, always, never] (default: auto)").required(false) .long_help("Whether the upload distributes the original source tarball(s), like dpkg-genchanges' -sa/-si/-sd source styles.\nauto: include them only when the upstream version changed since the previous changelog entry (the default);\nalways: force inclusion, even for a revision bump of the same upstream version;\nnever: never include them, even for a new upstream version.\nAn explicit value is ignored with a warning for native packages (they have no separate orig tarball).") .value_parser(["auto", "always", "never"])), ) .subcommand( Command::new("put") .about("Upload the built source package to a PPA") .arg(arg!(--ppa "Upload to a PPA (format: user/ppa_name)")) .arg(arg!([changes] "Explicit .changes file to upload (default: the one built from this package, next to the source tree)").required(false)) .arg(arg!(--force "Upload even if this exact .changes file was already uploaded to the target")), ) .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!(-j --jobs "Number of parallel build jobs (default: number of CPUs available in the build context)").required(false)) .arg( clap::Arg::new("resume") .long("resume") .value_name("ID") .num_args(0..=1) .require_equals(false) .default_missing_value("") .help("Resume a previous build session: reuse the chroot, build dependencies and build artifacts") .long_help("Resume a previous build session of this tree: the prepared chroot and installed build dependencies are reused, and the build continues in the staged tree (make recompiles only what changed).\nWithout a value, the newest session of this tree is adopted; with an ID (as shown by 'pkh deb list'), that exact session.\nA plain build never reuses a session: everything is rechecked from scratch."), ) .arg(arg!(--keep "Keep the build session after a successful build (for a later --resume)") .long_help("Keep the build session after a successful build, so a later 'pkh deb --resume' can iterate on it (e.g. after editing sources).\nWithout it, a successful build consumes the session (failures and interrupts always keep it).")) .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("list") .about("List the resumable build sessions recorded from this tree") .long_about("List the resumable build sessions recorded from this tree, one per target (series/arch), with the id to pass to 'pkh deb --resume '.\nRead-only: never builds or removes anything ('pkh prune' removes old sessions)."), ) ) .subcommand( Command::new("test") .about("Run the package's DEP-8 as-installed tests (autopkgtests)") .arg(arg!([path] "Source tree to test (default: the current directory)").required(false)) .arg(arg!(-s --series "Target distribution series").required(false)) .arg(arg!(-d --dist "Target distribution (debian, ubuntu)").required(false)) .arg(arg!(-a --arch "Target architecture").required(false)) .arg(arg!(-p --pocket "Resolve test dependencies from a specific distribution pocket (updates, security, proposed)").required(false)) .arg(arg!(--ppa "Add a PPA as test dependency source (can be specified multiple times)").required(false).action(clap::ArgAction::Append)) .arg(arg!(--inject "Inject a package into the testbed before the tests (.deb path or package name, can be specified multiple times)").required(false).action(clap::ArgAction::Append)) .arg(arg!(--mode "Testbed mode: chroot (default, an ephemeral unshare chroot), local (the current context, no isolation) or vm (a qemu VM through autopkgtest)").required(false)) .arg(arg!(--image "VM image for --mode vm (required there)").required(false)) .arg(arg!(--cpus "VM virtual CPUs (vm mode)").required(false).value_parser(clap::value_parser!(u32))) .arg(arg!(--"ram-size" "VM memory size in MiB (vm mode)").required(false).value_parser(clap::value_parser!(u32))) .arg(arg!(--debs "Explicit binary package to test, overriding the pkh deb output reuse (can be specified multiple times)").required(false).action(clap::ArgAction::Append)) .arg( clap::Arg::new("no_build") .long("no-build") .action(clap::ArgAction::SetTrue) .help("Never build: fail when no fresh 'pkh deb' output exists next to the tree"), ) .arg(arg!(--build "Ignore fresh pkh deb output and rebuild the binaries first").required(false)) .arg( clap::Arg::new("test_name") .long("test-name") .value_name("NAME") .action(clap::ArgAction::Append) .help("Run only this test (can be specified multiple times)"), ) .arg( clap::Arg::new("skip_test") .long("skip-test") .value_name("NAME") .action(clap::ArgAction::Append) .help("Skip this test, reporting it as skipped (can be specified multiple times)"), ) .arg( clap::Arg::new("setup_commands") .long("setup-commands") .value_name("CMD") .action(clap::ArgAction::Append) .help("Shell command run in the testbed before the tests (can be specified multiple times)"), ) .arg( clap::Arg::new("apt_upgrade") .long("apt-upgrade") .action(clap::ArgAction::SetTrue) .help("Update, upgrade and autopurge the testbed before the tests"), ) .arg(arg!(--shell "Drop into an interactive testbed shell after the run").required(false)) .arg( clap::Arg::new("shell_fail") .long("shell-fail") .action(clap::ArgAction::SetTrue) .help("Drop into an interactive testbed shell when a test failed"), ) .arg(arg!(--timeout "Per-test timeout in seconds (default: 10000)").required(false).value_parser(clap::value_parser!(u64))) .arg( clap::Arg::new("timeout_factor") .long("timeout-factor") .value_name("FACTOR") .value_parser(clap::value_parser!(f64)) .help("Scale the default timeouts (an explicit --timeout stands as given)"), ) .arg( clap::Arg::new("fail_on") .long("fail-on") .value_name("TRIGGERS") .help("Comma-separated extra failure triggers: skip, no-tests (default: none)"), ) .arg( clap::Arg::new("list_tests") .long("list-tests") .action(clap::ArgAction::SetTrue) .help("Print the tests declared in debian/tests/control and exit"), ) .arg(arg!(--json "Emit the report as JSON").required(false)) .arg(arg!(--verbose "Show raw test output instead of the summarized live view").required(false)), ) .subcommand( Command::new("lint") .about("Lint the package (lintian wrapper + pkh-native checks)") .arg(arg!([path] "Source tree to lint (default: the current directory)").required(false)) .arg(arg!(-d --dist "Target distribution (debian, ubuntu)").required(false)) .arg(arg!(-s --series "Target distribution series").required(false)) .arg(arg!(--native "Run pkh-native checks only, without the lintian wrapper").required(false)) .arg(arg!(--info "Show tag explanations under each finding").required(false)) .arg( clap::Arg::new("display_info") .long("display-info") .action(clap::ArgAction::SetTrue) .help("Also display info-level tags (I:)"), ) .arg(arg!(--pedantic "Also display pedantic tags (P:)").required(false)) .arg(arg!(--experimental "Also display experimental tags (X:)").required(false)) .arg( clap::Arg::new("show_overrides") .long("show-overrides") .action(clap::ArgAction::SetTrue) .help("Also display overridden tags (O:)"), ) .arg( clap::Arg::new("fail_on") .long("fail-on") .value_name("LEVELS") .help("Comma-separated severities failing the run: error, warning, info, pedantic, experimental, override (default: error)"), ) .arg( clap::Arg::new("suppress_tags") .long("suppress-tags") .value_name("LIST") .help("Comma-separated tag names to ignore for this run"), ) .arg( clap::Arg::new("check") .long("check") .value_name("NAME") .action(clap::ArgAction::Append) .help("Run only this pkh-native check (can be specified multiple times)"), ) .arg(arg!(--repack "Ignore existing pkh build output and pack the tree fresh for linting").required(false)) .arg(arg!(--json "Emit the report as JSON").required(false)) .arg( clap::Arg::new("color") .long("color") .value_name("WHEN") .value_parser(["auto", "always", "never"]) .help("Colorize the report: auto, always or never (default: auto)"), ) .arg( clap::Arg::new("list_tags") .long("list-tags") .action(clap::ArgAction::SetTrue) .help("Print the pkh-native tag catalog and exit"), ), ) .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(("new", sub_matches)) => { let depends: Vec = sub_matches .get_many::("depends") .map(|values| values.cloned().collect()) .unwrap_or_default(); let no_verify = sub_matches .get_one::("no_verify") .copied() .unwrap_or(false); let cli = pkh::new::options::NewCli { name: sub_matches.get_one::("name").cloned(), lang: sub_matches.get_one::("lang").cloned(), source: sub_matches .get_one::("source") .map(std::path::PathBuf::from), upstream_version: sub_matches.get_one::("upstream_version").cloned(), revision: sub_matches.get_one::("revision").copied(), description: sub_matches.get_one::("description").cloned(), homepage: sub_matches.get_one::("homepage").cloned(), license: sub_matches.get_one::("license").cloned(), command: sub_matches.get_one::("command").cloned(), maintainer: sub_matches.get_one::("maintainer").cloned(), depends, dist: sub_matches.get_one::("dist").cloned(), series: sub_matches.get_one::("series").cloned(), release: sub_matches .get_one::("release") .copied() .unwrap_or(false), native: sub_matches .get_one::("native") .copied() .unwrap_or(false), quilt: sub_matches .get_one::("quilt") .copied() .unwrap_or(false), orig_from: sub_matches.get_one::("orig_from").cloned(), orig_path: sub_matches.get_one::("orig_path").cloned(), git: !sub_matches .get_one::("no_git") .copied() .unwrap_or(false), defaults: sub_matches .get_one::("defaults") .copied() .unwrap_or(false), }; // The wizard (interactive terminal) fills the same NewCli and // resolves through the same pipeline; without a TTY the resolve // error lists every missing answer. Afterwards the two // verification builds are offered (`--no-verify` skips them; // the structural self-checks inside `scaffold` always run), with // the scaffold outcome (e.g. a failed vendoring) shaping the // offer. let prompter = pkh::ui::prompt::TerminalPrompter; if let Err(e) = rt.block_on(async { let opts = pkh::new::questions::run(cli, &prompter).await?; let outcome = pkh::new::scaffold(opts.clone(), &multi)?; pkh::new::questions::offer_verification( &opts, &outcome, &multi, no_verify, &prompter, ) .await; Ok::<(), Box>(()) }) { error!("{}", e); std::process::exit(1); } } 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 repository = sub_matches .get_one::("repository") .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 = match ppa.map(pkh::package_info::split_ppa) { Some(Ok((user, name))) => Some(pkh::package_info::ppa_to_base_url(user, name)), Some(Err(e)) => { error!("{e}"); std::process::exit(1); } None => 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(), repository, 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()); let kind = if sub_matches .get_one::("backport") .copied() .unwrap_or(false) { pkh::changelog::EntryKind::Backport } else if sub_matches.get_one::("nmu").copied().unwrap_or(false) { pkh::changelog::EntryKind::Nmu } else if sub_matches .get_one::("rebuild") .copied() .unwrap_or(false) { pkh::changelog::EntryKind::Rebuild } else { pkh::changelog::EntryKind::Normal }; // Determine target series: CLI flag > interactive selector > current changelog series let target_series = if let Some(s) = cli_series { Some(s.to_string()) } else { let changelog_path = cwd.join("debian/changelog"); match rt.block_on(pkh::changelog::series_candidates(&changelog_path)) { Some(pkh::changelog::SeriesCandidates::Choose { options, values, default, fallback, }) => match pkh::ui::select_series(&options, &default) { Ok(selected) => { Some(pkh::changelog::selected_series(&options, &values, selected)) } Err(e) => { error!( "Series selection failed: {}. Using current series '{}' instead.", e, fallback ); Some(fallback) } }, // Could not fetch the series list: use the current series Some(pkh::changelog::SeriesCandidates::Keep(current)) => Some(current), // No parsable changelog: leave the series decision to // generate_entry None => None, } }; let entry = match rt.block_on(pkh::changelog::generate_entry( "debian/changelog", Some(&cwd), version, target_series.as_deref(), kind, )) { Ok(entry) => entry, Err(e) => { error!("{}", e); std::process::exit(1); } }; println!( "Found package: {}, version: {}", entry.package, entry.previous_version ); println!("New version: {}", entry.new_version); println!("Added new changelog entry to {}", entry.path.display()); 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(); interrupt::install(); let verbose = sub_matches .get_one::("verbose") .copied() .unwrap_or(false); // Live build view, unless --verbose (DebUi additionally disables // itself when stdout is not a terminal) let quiet = pkh::report::Quiet; let live = if verbose { None } else { Some(pkh::ui::deb::DebUi::new(&multi)) }; let view: &dyn pkh::report::BuildView = live .as_ref() .map(|v| v as &dyn pkh::report::BuildView) .unwrap_or(&quiet); let prompter = pkh::ui::prompt::TerminalPrompter; let orig_source = match sub_matches.get_one::("orig").map(String::as_str) { Some("always") => pkh::build::OrigSourceMode::Always, Some("never") => pkh::build::OrigSourceMode::Never, _ => pkh::build::OrigSourceMode::Auto, }; match pkh::build::build_source_package(pkh::build::BuildSourceOptions { source: Some(cwd), options: pkh::build::SourceBuildOptions { orig_source, ..Default::default() }, view, prompter: &prompter, }) { Ok(output) => { // The live view lists the artifacts itself when it // renders; otherwise (verbose mode or non-TTY stdout) // print them as plain lines. if !view.is_enabled() { for artifact in output.artifacts() { println!(" {}", pkh::report::display_path(&artifact)); } } if output.signed { println!("Package built and signed successfully!"); } else { println!("Package built successfully (unsigned)."); } } Err(e) => { // On Ctrl+C the interrupt watchdog owns the shutdown // (see `pkh deb`): park here instead of racing it if pkh::interrupt::interrupted() { interrupt::wait_for_shutdown(); } // The unmet-dependency diagnostics first, then the // summary: the exact rendering the flow used to do. if let Some(unmet) = e.downcast_ref::() { eprintln!("{}", unmet.0.message()); } 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(("put", sub_matches)) => { let cwd = current_dir_or_exit(); interrupt::install(); let ppa = sub_matches.get_one::("ppa").map(|s| s.as_str()); let changes = sub_matches .get_one::("changes") .map(std::path::PathBuf::from); let force = sub_matches .get_one::("force") .copied() .unwrap_or(false); // Only PPA targets are implemented for now let Some(ppa) = ppa else { error!( "pkh put needs a target: pass --ppa user/ppa_name \ (archive uploads are not supported yet)" ); std::process::exit(1); }; let view = pkh::ui::deb::DebUi::new(&multi); let prompter = pkh::ui::prompt::TerminalPrompter; let options = pkh::put::PutOptions { ppa: ppa.to_string(), changes, force, cwd, view: &view, prompter: &prompter, }; if let Err(e) = rt.block_on(async { pkh::put::put(&options).await }) { // On Ctrl+C the interrupt watchdog owns the shutdown (see // `pkh deb`): park here instead of racing it if pkh::interrupt::interrupted() { interrupt::wait_for_shutdown(); } error!("{}", e); std::process::exit(1); } } Some(("deb", sub_matches)) => { let cwd = current_dir_or_exit(); // `pkh deb list`: read-only listing of the resumable build // sessions recorded from this tree; never touches the build // machinery. if let Some(("list", _)) = sub_matches.subcommand() { let tree = cwd.canonicalize().unwrap_or(cwd.clone()); let rows = pkh::deb::session::list_for_tree(&tree); print!("{}", pkh::deb::session::render_session_list(&tree, &rows)); return; } // Ctrl+C during the build must say what happened and release the // ephemeral chroot instead of dying on the default disposition. // The live view (when enabled) registers its own reporter on top // of this to clear the widget first. interrupt::install(); let series = sub_matches.get_one::("series").cloned(); let pocket = sub_matches.get_one::("pocket").cloned(); let arch = sub_matches.get_one::("arch").cloned(); let cross = sub_matches .get_one::("cross") .copied() .unwrap_or(false); let ppa: Vec = sub_matches .get_many::("ppa") .map(|v| v.cloned().collect()) .unwrap_or_default(); let inject: Vec = sub_matches .get_many::("inject") .map(|v| v.cloned().collect()) .unwrap_or_default(); 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); let jobs = sub_matches.get_one::("jobs").map(|s| s.as_str()); let jobs = jobs.map(|j| { j.parse::().unwrap_or_else(|_| { error!("Invalid --jobs value '{}': expected a positive integer", j); std::process::exit(1); }) }); // Session handling: --resume [] and --keep (see the deb // subcommand help). let resume = match sub_matches.get_one::("resume").map(String::as_str) { Some("") => pkh::deb::SessionResume::Latest, Some(id) => pkh::deb::SessionResume::Id(id.to_string()), None => pkh::deb::SessionResume::Fresh, }; let keep_session = sub_matches.get_flag("keep"); // Live build view, unless --verbose (DebUi additionally disables // itself when stdout is not a terminal) let quiet = pkh::report::Quiet; let live = if verbose { None } else { Some(pkh::ui::deb::DebUi::new(&multi)) }; let view: &dyn pkh::report::BuildView = live .as_ref() .map(|v| v as &dyn pkh::report::BuildView) .unwrap_or(&quiet); let result = rt.block_on(async { pkh::deb::build_binary_package(pkh::deb::DebBuildOptions { arch, series, pocket, cwd: Some(cwd.clone()), cross, mode, ppa, inject, jobs, resume, keep_session, view, ..Default::default() }) .await }); match result { Ok(_) => info!("Done."), Err(e) => { // On Ctrl+C the interrupt watchdog owns the shutdown: it // has already shown the notice, is releasing the build // resources, and will exit with 130 — park here instead // of racing it with another exit. if pkh::interrupt::interrupted() { interrupt::wait_for_shutdown(); } error!("{}", e); std::process::exit(1); } } } Some(("test", sub_matches)) => { let path = sub_matches .get_one::("path") .map(std::path::PathBuf::from); // Read-only listing of the declared tests; never boots a // testbed. if sub_matches.get_flag("list_tests") { let tree = path.unwrap_or_else(current_dir_or_exit); match pkh::test::list_tests(&tree) { Ok(listing) => print!("{listing}"), Err(e) => { error!("{e}"); std::process::exit(2); } } return; } // Ctrl+C must release the ephemeral chroot instead of dying // on the default disposition (the deb pattern). interrupt::install(); let mode = match sub_matches.get_one::("mode").map(String::as_str) { None => None, Some(value) => match pkh::test::TestMode::parse(value) { Some(mode) => Some(mode), None => { error!("Invalid --mode value '{value}': expected chroot or local"); std::process::exit(1); } }, }; let (fail_on_skip, fail_on_no_tests) = match sub_matches.get_one::("fail_on").map(String::as_str) { None => (false, false), Some(list) => { let mut skip = false; let mut no_tests = false; for trigger in list.split(',').map(str::trim).filter(|t| !t.is_empty()) { match trigger { "skip" => skip = true, "no-tests" => no_tests = true, other => { error!( "Unknown --fail-on trigger '{other}': \ expected skip or no-tests" ); std::process::exit(1); } } } (skip, no_tests) } }; // Live build view, unless --verbose (DebUi additionally // disables itself when stdout is not a terminal). let quiet = pkh::report::Quiet; let live = if sub_matches.get_flag("verbose") { None } else { Some(pkh::ui::deb::DebUi::new(&multi)) }; let view: &dyn pkh::report::BuildView = live .as_ref() .map(|v| v as &dyn pkh::report::BuildView) .unwrap_or(&quiet); let options = pkh::test::TestOptions { tree: path, series: sub_matches.get_one::("series").cloned(), dist: sub_matches.get_one::("dist").cloned(), arch: sub_matches.get_one::("arch").cloned(), pocket: sub_matches.get_one::("pocket").cloned(), ppa: sub_matches .get_many::("ppa") .map(|v| v.cloned().collect()) .unwrap_or_default(), inject: sub_matches .get_many::("inject") .map(|v| v.cloned().collect()) .unwrap_or_default(), mode, image: sub_matches .get_one::("image") .map(std::path::PathBuf::from), cpus: sub_matches.get_one::("cpus").copied(), ram_size: sub_matches .get_one::("ram-size") .map(|v| v.to_string()), debs: sub_matches .get_many::("debs") .map(|v| v.map(std::path::PathBuf::from).collect()) .unwrap_or_default(), no_build: sub_matches.get_flag("no_build"), build: sub_matches.get_flag("build"), test_names: sub_matches .get_many::("test_name") .map(|v| v.cloned().collect()) .unwrap_or_default(), skip_tests: sub_matches .get_many::("skip_test") .map(|v| v.cloned().collect()) .unwrap_or_default(), setup_commands: sub_matches .get_many::("setup_commands") .map(|v| v.cloned().collect()) .unwrap_or_default(), apt_upgrade: sub_matches.get_flag("apt_upgrade"), shell: sub_matches.get_flag("shell"), shell_fail: sub_matches.get_flag("shell_fail"), timeout: sub_matches.get_one::("timeout").copied(), timeout_factor: sub_matches.get_one::("timeout_factor").copied(), fail_on_skip, fail_on_no_tests, json: sub_matches.get_flag("json"), verbose: sub_matches.get_flag("verbose"), view, }; let json = options.json; match rt.block_on(pkh::test::run(options)) { Ok(report) => { // The per-test report is the deliverable; the live // view carries phases and the outcome banner only. if json { println!("{}", report.render_json()); } else { print!("{}", report.render_text()); } std::process::exit(report.exit_code(fail_on_skip, fail_on_no_tests)); } Err(e) => { // On Ctrl+C the interrupt watchdog owns the shutdown // (see `pkh deb`): park here instead of racing it. if pkh::interrupt::interrupted() { interrupt::wait_for_shutdown(); } error!("{e}"); std::process::exit(2); } } } 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); } } } Some(("lint", sub_matches)) => { if sub_matches.get_flag("list_tags") { print!("{}", pkh::lint::list_tags()); std::process::exit(0); } let path = sub_matches .get_one::("path") .map(std::path::PathBuf::from) .unwrap_or_else(current_dir_or_exit); let fail_on = match pkh::lint::output::parse_fail_on( sub_matches .get_one::("fail_on") .map(String::as_str) .unwrap_or("error"), ) { Ok(levels) => levels, Err(e) => { error!("{}", e); std::process::exit(2); } }; let options = pkh::lint::LintOptions { path, native: sub_matches.get_flag("native"), fail_on, info: sub_matches.get_flag("info"), display_info: sub_matches.get_flag("display_info"), pedantic: sub_matches.get_flag("pedantic"), experimental: sub_matches.get_flag("experimental"), show_overrides: sub_matches.get_flag("show_overrides"), suppress_tags: sub_matches .get_one::("suppress_tags") .map(|list| { list.split(',') .map(str::trim) .filter(|tag| !tag.is_empty()) .map(str::to_string) .collect() }) .unwrap_or_default(), only_checks: sub_matches .get_many::("check") .map(|values| values.cloned().collect()) .unwrap_or_default(), repack: sub_matches.get_flag("repack"), json: sub_matches.get_flag("json"), color: match sub_matches.get_one::("color").map(String::as_str) { Some("always") => pkh::lint::output::ColorMode::Always, Some("never") => pkh::lint::output::ColorMode::Never, _ => pkh::lint::output::ColorMode::Auto, }, dist: sub_matches.get_one::("dist").cloned(), series: sub_matches.get_one::("series").cloned(), }; match pkh::lint::run(&options) { Ok(report) => { if options.json { println!("{}", pkh::lint::output::render_json(&report, &options)); } else { print!("{}", pkh::lint::output::render_text(&report, &options)); } std::process::exit(pkh::lint::output::exit_code(&report, &options)); } Err(e) => { error!("{}", e); std::process::exit(2); } } } _ => unreachable!("Exhausted list of subcommands and subcommand_required prevents `None`"), } } #[cfg(test)] mod tests { use super::interrupt; /// End-to-end check of the whole sequence: installed handler → self-pipe /// → watchdog → notice + hooks → exit status 130. The sequence ends in /// `libc::_exit`, so it cannot be exercised in-process: this test /// re-spawns the test binary in child mode (env var), where the test /// installs the handler and raises SIGINT at itself. #[test] fn sigint_sequence_prints_the_notice_and_exits_130() { const CHILD_ENV: &str = "PKH_SIGINT_TEST_CHILD"; if std::env::var(CHILD_ENV).is_ok() { // Child mode: install, register a pending hook, then interrupt // ourselves. If the sequence never runs, the sleep below turns // the failure into a wrong (zero) exit code instead of a hang. interrupt::install(); // Alive until the watchdog drains it: a dropped guard would // deregister the hook and the drain would run empty let _hook = pkh::interrupt::register_cleanup_hook(Box::new(|| ())); // SAFETY: kill(2) to our own process with SIGINT unsafe { libc::kill(libc::getpid(), libc::SIGINT); } std::thread::sleep(std::time::Duration::from_secs(30)); std::process::exit(0); } let exe = std::env::current_exe().expect("locate the test executable"); let output = std::process::Command::new(exe) // --nocapture: libtest's capture buffer would otherwise swallow // the watchdog's notice (threads spawned during a test inherit // the capture), and the process exits before the harness prints // anything it captured .args([ "--exact", "tests::sigint_sequence_prints_the_notice_and_exits_130", "--test-threads=1", "--nocapture", ]) .env(CHILD_ENV, "1") .output() .expect("re-spawn the test binary"); let stderr = String::from_utf8_lossy(&output.stderr); assert_eq!(output.status.code(), Some(130), "child stderr:\n{stderr}"); assert!( stderr.contains("CTRL+C: Build interrupted by user."), "child stderr:\n{stderr}" ); } }