Wiring for the session feature: --resume optionally takes a session id (bare --resume adopts the newest session of the tree), --keep keeps the session after a successful build, and 'pkh deb list' renders the read-only session table for the current tree. Listing is a subcommand, not a mode-switching flag: pkh deb with flags always starts a build.
1084 lines
50 KiB
Rust
1084 lines
50 KiB
Rust
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 ./<name>/ 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 ./<name>/"),
|
|
)
|
|
.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 <EMAIL>")
|
|
.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 <series> "Target package distribution series").required(false),
|
|
)
|
|
.arg(
|
|
arg!(-d --dist <dist> "Target package distribution (debian, ubuntu)")
|
|
.required(false),
|
|
)
|
|
.arg(arg!(-v --version <version> "Target package version").required(false))
|
|
.arg(arg!(--archive "Only use the archive to download package source, not git").required(false))
|
|
.arg(arg!(--ppa <ppa> "Download the package from a specific PPA (format: user/ppa_name)").required(false))
|
|
.arg(arg!(--repository <url> "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 <pocket> "Target package distribution pocket (updates, security, proposed)").required(false))
|
|
.arg(arg!(<package> "Target package")),
|
|
)
|
|
.subcommand(
|
|
Command::new("chlog")
|
|
.about("Auto-generate changelog entry, editing it, committing it afterwards")
|
|
.arg(arg!(-s --series <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 <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 <when> "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 <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 <series> "Target distribution series").required(false))
|
|
.arg(arg!(-a --arch <arch> "Target architecture").required(false))
|
|
.arg(arg!(-p --pocket <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 '<series>-<pocket>' suite will be enabled on archive sources when resolving build-dependencies."))
|
|
.arg(arg!(--ppa <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 <package> "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 <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 <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 <id>'.\nRead-only: never builds or removes anything ('pkh prune' removes old sessions)."),
|
|
)
|
|
)
|
|
.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 <dist> "Target distribution (debian, ubuntu)").required(false))
|
|
.arg(arg!(-s --series <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<String> = sub_matches
|
|
.get_many::<String>("depends")
|
|
.map(|values| values.cloned().collect())
|
|
.unwrap_or_default();
|
|
let no_verify = sub_matches
|
|
.get_one::<bool>("no_verify")
|
|
.copied()
|
|
.unwrap_or(false);
|
|
let cli = pkh::new::options::NewCli {
|
|
name: sub_matches.get_one::<String>("name").cloned(),
|
|
lang: sub_matches.get_one::<String>("lang").cloned(),
|
|
source: sub_matches
|
|
.get_one::<String>("source")
|
|
.map(std::path::PathBuf::from),
|
|
upstream_version: sub_matches.get_one::<String>("upstream_version").cloned(),
|
|
revision: sub_matches.get_one::<u32>("revision").copied(),
|
|
description: sub_matches.get_one::<String>("description").cloned(),
|
|
homepage: sub_matches.get_one::<String>("homepage").cloned(),
|
|
license: sub_matches.get_one::<String>("license").cloned(),
|
|
command: sub_matches.get_one::<String>("command").cloned(),
|
|
maintainer: sub_matches.get_one::<String>("maintainer").cloned(),
|
|
depends,
|
|
dist: sub_matches.get_one::<String>("dist").cloned(),
|
|
series: sub_matches.get_one::<String>("series").cloned(),
|
|
release: sub_matches
|
|
.get_one::<bool>("release")
|
|
.copied()
|
|
.unwrap_or(false),
|
|
native: sub_matches
|
|
.get_one::<bool>("native")
|
|
.copied()
|
|
.unwrap_or(false),
|
|
quilt: sub_matches
|
|
.get_one::<bool>("quilt")
|
|
.copied()
|
|
.unwrap_or(false),
|
|
orig_from: sub_matches.get_one::<String>("orig_from").cloned(),
|
|
orig_path: sub_matches.get_one::<String>("orig_path").cloned(),
|
|
git: !sub_matches
|
|
.get_one::<bool>("no_git")
|
|
.copied()
|
|
.unwrap_or(false),
|
|
defaults: sub_matches
|
|
.get_one::<bool>("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<dyn std::error::Error>>(())
|
|
}) {
|
|
error!("{}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
Some(("pull", sub_matches)) => {
|
|
let package = sub_matches.get_one::<String>("package").expect("required");
|
|
let series = sub_matches.get_one::<String>("series").map(|s| s.as_str());
|
|
let dist = sub_matches.get_one::<String>("dist").map(|s| s.as_str());
|
|
let version = sub_matches.get_one::<String>("version").map(|s| s.as_str());
|
|
let ppa = sub_matches.get_one::<String>("ppa").map(|s| s.as_str());
|
|
let repository = sub_matches
|
|
.get_one::<String>("repository")
|
|
.map(|s| s.as_str());
|
|
let pocket = sub_matches
|
|
.get_one::<String>("pocket")
|
|
.map(|s| s.as_str())
|
|
.unwrap_or("");
|
|
let archive = sub_matches.get_one::<bool>("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::<String>("version").map(|s| s.as_str());
|
|
let cli_series = sub_matches.get_one::<String>("series").map(|s| s.as_str());
|
|
let kind = if sub_matches
|
|
.get_one::<bool>("backport")
|
|
.copied()
|
|
.unwrap_or(false)
|
|
{
|
|
pkh::changelog::EntryKind::Backport
|
|
} else if sub_matches.get_one::<bool>("nmu").copied().unwrap_or(false) {
|
|
pkh::changelog::EntryKind::Nmu
|
|
} else if sub_matches
|
|
.get_one::<bool>("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::<bool>("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::<String>("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::<pkh::debian::deps::UnmetBuildDependencies>()
|
|
{
|
|
eprintln!("{}", unmet.0.message());
|
|
}
|
|
error!("{}", e);
|
|
// Unmet build dependencies/conflicts exit with status 3,
|
|
// like dpkg-buildpackage does.
|
|
if e.downcast_ref::<pkh::debian::deps::UnmetBuildDependencies>()
|
|
.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::<String>("ppa").map(|s| s.as_str());
|
|
let changes = sub_matches
|
|
.get_one::<String>("changes")
|
|
.map(std::path::PathBuf::from);
|
|
let force = sub_matches
|
|
.get_one::<bool>("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::<String>("series").cloned();
|
|
let pocket = sub_matches.get_one::<String>("pocket").cloned();
|
|
let arch = sub_matches.get_one::<String>("arch").cloned();
|
|
let cross = sub_matches
|
|
.get_one::<bool>("cross")
|
|
.copied()
|
|
.unwrap_or(false);
|
|
let ppa: Vec<String> = sub_matches
|
|
.get_many::<String>("ppa")
|
|
.map(|v| v.cloned().collect())
|
|
.unwrap_or_default();
|
|
let inject: Vec<String> = sub_matches
|
|
.get_many::<String>("inject")
|
|
.map(|v| v.cloned().collect())
|
|
.unwrap_or_default();
|
|
let mode: Option<&str> = sub_matches.get_one::<String>("mode").map(|s| s.as_str());
|
|
let mode: Option<pkh::deb::BuildMode> = match mode {
|
|
Some("local") => Some(pkh::deb::BuildMode::Local),
|
|
_ => None,
|
|
};
|
|
let verbose = sub_matches
|
|
.get_one::<bool>("verbose")
|
|
.copied()
|
|
.unwrap_or(false);
|
|
|
|
let jobs = sub_matches.get_one::<String>("jobs").map(|s| s.as_str());
|
|
let jobs = jobs.map(|j| {
|
|
j.parse::<usize>().unwrap_or_else(|_| {
|
|
error!("Invalid --jobs value '{}': expected a positive integer", j);
|
|
std::process::exit(1);
|
|
})
|
|
});
|
|
|
|
// Session handling: --resume [<id>] and --keep (see the deb
|
|
// subcommand help).
|
|
let resume = match sub_matches.get_one::<String>("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(("prune", sub_matches)) => {
|
|
let dry_run = sub_matches
|
|
.get_one::<bool>("dry_run")
|
|
.copied()
|
|
.unwrap_or(false);
|
|
let all = sub_matches.get_one::<bool>("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::<String>("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::<String>("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::<String>("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::<String>("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::<String>("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::<String>("dist").cloned(),
|
|
series: sub_matches.get_one::<String>("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}"
|
|
);
|
|
}
|
|
}
|