Compare commits
11
Commits
a7d2cfdc6e
...
47bb7c608e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47bb7c608e | ||
|
|
6caedce61a | ||
|
|
bd8f814a53 | ||
|
|
4fae02bc85 | ||
|
|
c2dae4f3f9 | ||
|
|
0421a91e01 | ||
|
|
54cb04ba27 | ||
|
|
bb76e41908 | ||
|
|
d64e472845 | ||
|
|
052c02cdc3 | ||
|
|
27b1083b15 |
+126
-121
@@ -14,7 +14,6 @@ pub mod env;
|
|||||||
|
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::io::IsTerminal;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -24,8 +23,8 @@ use crate::context::{LineSink, Stream};
|
|||||||
use crate::debian::{
|
use crate::debian::{
|
||||||
ChecksumEntry, ControlInfo, FileChecksums, FilesEntry, FilesList, parse_paragraphs,
|
ChecksumEntry, ControlInfo, FileChecksums, FilesEntry, FilesList, parse_paragraphs,
|
||||||
};
|
};
|
||||||
use crate::ui::deb::DebUi;
|
use crate::logfmt::{DpkgSourceClassifier, GenericClassifier};
|
||||||
use crate::ui::logfmt::{DpkgSourceClassifier, GenericClassifier};
|
use crate::report::{BuildTarget, BuildView, Prompter};
|
||||||
|
|
||||||
/// Whether the upload distributes the upstream orig tarballs (`--orig`),
|
/// Whether the upload distributes the upstream orig tarballs (`--orig`),
|
||||||
/// mirroring the `dpkg-genchanges` source styles.
|
/// mirroring the `dpkg-genchanges` source styles.
|
||||||
@@ -74,105 +73,118 @@ pub struct SourceBuildOutput {
|
|||||||
pub signed: bool,
|
pub signed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SourceBuildOutput {
|
||||||
|
/// All produced artifacts in distribution order: dsc → tarballs →
|
||||||
|
/// buildinfo → changes.
|
||||||
|
pub fn artifacts(&self) -> Vec<PathBuf> {
|
||||||
|
let mut artifacts = Vec::with_capacity(3 + self.tarballs.len());
|
||||||
|
artifacts.push(self.dsc.clone());
|
||||||
|
artifacts.extend(self.tarballs.iter().cloned());
|
||||||
|
artifacts.push(self.buildinfo.clone());
|
||||||
|
artifacts.push(self.changes.clone());
|
||||||
|
artifacts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters of one [`build_source_package`] call: where to build, the
|
||||||
|
/// domain options, and the reporting ports (view and prompter).
|
||||||
|
pub struct BuildSourceOptions<'a> {
|
||||||
|
/// Source tree to package. When unset, the process's current working
|
||||||
|
/// directory is used (resolved to an absolute path).
|
||||||
|
pub source: Option<PathBuf>,
|
||||||
|
/// Domain options (signing, orig-tarball inclusion, ...).
|
||||||
|
pub options: SourceBuildOptions,
|
||||||
|
/// Where build events (target, phases, messages, outcome) are reported.
|
||||||
|
pub view: &'a dyn BuildView,
|
||||||
|
/// Who answers the questions the flow may ask (e.g. the re-vendor
|
||||||
|
/// retry offer).
|
||||||
|
pub prompter: &'a dyn Prompter,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BuildSourceOptions<'_> {
|
||||||
|
fn default() -> Self {
|
||||||
|
static QUIET: crate::report::Quiet = crate::report::Quiet;
|
||||||
|
BuildSourceOptions {
|
||||||
|
source: None,
|
||||||
|
options: SourceBuildOptions::default(),
|
||||||
|
view: &QUIET,
|
||||||
|
prompter: &QUIET,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Build a Debian source package (to a .dsc) using the native pipeline.
|
/// Build a Debian source package (to a .dsc) using the native pipeline.
|
||||||
///
|
///
|
||||||
/// When `ui` is set, subprocess output is captured into a live view (status
|
/// Subprocess output is captured into the view (status line + rolling
|
||||||
/// bar + rolling pane) and tee'd to a log file; on failure the view prints a
|
/// pane for the terminal adapter) and tee'd to a log file; on failure the
|
||||||
/// summary of the last captured errors. Without a UI, commands inherit the
|
/// view prints a summary of the last captured errors. Headless callers use
|
||||||
/// terminal as before.
|
/// [`crate::report::Quiet`], in which case commands still run with captured
|
||||||
|
/// output in test builds.
|
||||||
///
|
///
|
||||||
/// A `dpkg-source -b` failure is classified (see
|
/// A `dpkg-source -b` failure is classified (see
|
||||||
/// [`classify_dpkg_source_failure`]); when the vendored rust dependencies
|
/// [`classify_dpkg_source_failure`]); when the vendored rust dependencies
|
||||||
/// diverged from the orig-vendor component and a terminal is attached, the
|
/// diverged from the orig-vendor component, the flow asks the prompter
|
||||||
/// flow offers to re-vendor, recreate the component and retry the build
|
/// whether to re-vendor, recreates the component and retries the build
|
||||||
/// exactly once.
|
/// exactly once.
|
||||||
|
///
|
||||||
|
/// On success the produced artifacts are reported through the view and
|
||||||
|
/// returned.
|
||||||
pub fn build_source_package(
|
pub fn build_source_package(
|
||||||
cwd: Option<&Path>,
|
opts: BuildSourceOptions<'_>,
|
||||||
opts: SourceBuildOptions,
|
) -> Result<SourceBuildOutput, Box<dyn Error>> {
|
||||||
ui: Option<Arc<DebUi>>,
|
|
||||||
) -> Result<(), Box<dyn Error>> {
|
|
||||||
// Default to the process's current working directory, resolved to an
|
// Default to the process's current working directory, resolved to an
|
||||||
// absolute path: the output directory is derived from `cwd.parent()`
|
// absolute path: the output directory is derived from `cwd.parent()`
|
||||||
// downstream, which only yields a real directory for an absolute `cwd`
|
// downstream, which only yields a real directory for an absolute `cwd`
|
||||||
// (the parent of "." is the empty path).
|
// (the parent of "." is the empty path).
|
||||||
let cwd = match cwd {
|
let cwd = match opts.source {
|
||||||
Some(p) => p.to_path_buf(),
|
Some(ref p) => p.clone(),
|
||||||
None => std::env::current_dir()
|
None => std::env::current_dir()
|
||||||
.map_err(|e| format!("cannot determine the current working directory: {e}"))?,
|
.map_err(|e| format!("cannot determine the current working directory: {e}"))?,
|
||||||
};
|
};
|
||||||
let output = match run_source_build(&cwd, &opts, ui.clone()) {
|
let output = match run_source_build(&cwd, &opts.options, opts.view) {
|
||||||
Ok(output) => output,
|
Ok(output) => output,
|
||||||
Err(e) if e.downcast_ref::<VendorDriftError>().is_some() => {
|
Err(e) if e.downcast_ref::<VendorDriftError>().is_some() => {
|
||||||
return retry_after_revendor(&cwd, ui, opts, e);
|
return retry_after_revendor(&cwd, opts.view, opts.prompter, &opts.options, e);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if let Some(u) = &ui {
|
opts.view.finish_failure();
|
||||||
u.finish_failure();
|
|
||||||
}
|
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Artifact listing in dpkg order: dsc → tarballs → buildinfo → changes.
|
opts.view.finish_success(&output.artifacts());
|
||||||
let mut artifacts = Vec::with_capacity(3 + output.tarballs.len());
|
Ok(output)
|
||||||
artifacts.push(output.dsc.clone());
|
|
||||||
artifacts.extend(output.tarballs.iter().cloned());
|
|
||||||
artifacts.push(output.buildinfo.clone());
|
|
||||||
artifacts.push(output.changes.clone());
|
|
||||||
|
|
||||||
// The live view lists the artifacts itself when it renders; otherwise
|
|
||||||
// (verbose mode or non-TTY stdout) print them as plain lines.
|
|
||||||
let listed = match &ui {
|
|
||||||
Some(u) if u.is_enabled() => {
|
|
||||||
u.finish_success(&artifacts, u.elapsed());
|
|
||||||
true
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
if !listed {
|
|
||||||
for artifact in &artifacts {
|
|
||||||
println!(" {}", crate::ui::display_path(artifact));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if output.signed {
|
|
||||||
println!("Package built and signed successfully!");
|
|
||||||
} else {
|
|
||||||
println!("Package built successfully (unsigned).");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The re-vendor retry hook for a [`VendorDriftError`]: on an interactive
|
/// The re-vendor retry hook for a [`VendorDriftError`]: offer to re-run the
|
||||||
/// terminal, offer to re-run the vendoring step (the same helper the rust
|
/// vendoring step through the prompter (the same helper the rust template
|
||||||
/// template uses at scaffold time), recreate the `orig-vendor` component
|
/// uses at scaffold time), recreate the `orig-vendor` component from the
|
||||||
/// from the fresh `vendor/` tree and retry the source build exactly once.
|
/// fresh `vendor/` tree and retry the source build exactly once. Without an
|
||||||
/// Without a terminal (or on a declined offer) the original error is
|
/// accepting answer (headless prompters answer with the default, `false`)
|
||||||
/// returned untouched.
|
/// the original error is returned untouched.
|
||||||
fn retry_after_revendor(
|
fn retry_after_revendor(
|
||||||
cwd: &Path,
|
cwd: &Path,
|
||||||
ui: Option<Arc<DebUi>>,
|
view: &dyn BuildView,
|
||||||
opts: SourceBuildOptions,
|
prompter: &dyn Prompter,
|
||||||
|
opts: &SourceBuildOptions,
|
||||||
original: Box<dyn Error>,
|
original: Box<dyn Error>,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<SourceBuildOutput, Box<dyn Error>> {
|
||||||
let interactive = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
|
// The offer is only made when someone can answer it: headless prompters
|
||||||
if !interactive {
|
// answer with the default (false) without the error being logged here —
|
||||||
if let Some(u) = &ui {
|
// the caller logs the returned error itself, exactly once.
|
||||||
u.finish_failure();
|
if !prompter.interactive() {
|
||||||
}
|
view.finish_failure();
|
||||||
return Err(original);
|
return Err(original);
|
||||||
}
|
}
|
||||||
|
|
||||||
log::error!("{original}");
|
log::error!("{original}");
|
||||||
let retry = crate::ui::prompt::confirm(
|
if !prompter
|
||||||
"Re-vendor the Cargo dependencies and retry the build?",
|
.confirm(
|
||||||
false,
|
"Re-vendor the Cargo dependencies and retry the build?",
|
||||||
)
|
false,
|
||||||
.unwrap_or(false);
|
)
|
||||||
if !retry {
|
.unwrap_or(false)
|
||||||
if let Some(u) = &ui {
|
{
|
||||||
u.finish_failure();
|
view.finish_failure();
|
||||||
}
|
|
||||||
return Err(original);
|
return Err(original);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,9 +201,7 @@ fn retry_after_revendor(
|
|||||||
std::fs::remove_file(&config)?;
|
std::fs::remove_file(&config)?;
|
||||||
}
|
}
|
||||||
if !crate::new::templates::rust::vendor_dependencies(cwd)? {
|
if !crate::new::templates::rust::vendor_dependencies(cwd)? {
|
||||||
if let Some(u) = &ui {
|
view.finish_failure();
|
||||||
u.finish_failure();
|
|
||||||
}
|
|
||||||
return Err(
|
return Err(
|
||||||
"Re-vendoring did not complete: the tree is unchanged, fix the \
|
"Re-vendoring did not complete: the tree is unchanged, fix the \
|
||||||
vendoring by hand and build again."
|
vendoring by hand and build again."
|
||||||
@@ -218,7 +228,7 @@ fn retry_after_revendor(
|
|||||||
crate::new::orig::create_vendor_component(cwd, &entry.source, uversion)?;
|
crate::new::orig::create_vendor_component(cwd, &entry.source, uversion)?;
|
||||||
|
|
||||||
// 3. One retry, with the options of the original attempt.
|
// 3. One retry, with the options of the original attempt.
|
||||||
run_source_build(cwd, &opts, ui).map(|_| ())
|
run_source_build(cwd, opts, view)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the full native source-build pipeline in `cwd`.
|
/// Run the full native source-build pipeline in `cwd`.
|
||||||
@@ -237,14 +247,12 @@ fn retry_after_revendor(
|
|||||||
pub fn run_source_build(
|
pub fn run_source_build(
|
||||||
cwd: &Path,
|
cwd: &Path,
|
||||||
opts: &SourceBuildOptions,
|
opts: &SourceBuildOptions,
|
||||||
ui: Option<Arc<DebUi>>,
|
view: &dyn BuildView,
|
||||||
) -> Result<SourceBuildOutput, Box<dyn Error>> {
|
) -> Result<SourceBuildOutput, Box<dyn Error>> {
|
||||||
// Without a live UI, test runs still capture command output into the
|
// The view consumes the captured lines itself (live view + tee log);
|
||||||
// per-test log file instead of letting it inherit the terminal
|
// without one, test runs still capture command output into the per-test
|
||||||
let sink: Option<Arc<dyn LineSink>> = ui
|
// log file instead of letting it inherit the terminal
|
||||||
.as_ref()
|
let sink: Option<Arc<dyn LineSink>> = view.sink().or_else(crate::test_support::subprocess_sink);
|
||||||
.map(|u| u.sink())
|
|
||||||
.or_else(crate::test_support::subprocess_sink);
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// 1. Sanity checks
|
// 1. Sanity checks
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
@@ -296,9 +304,19 @@ pub fn run_source_build(
|
|||||||
|
|
||||||
let ctrl = ControlInfo::parse(&control_path)?;
|
let ctrl = ControlInfo::parse(&control_path)?;
|
||||||
|
|
||||||
if let Some(u) = &ui {
|
view.target(BuildTarget {
|
||||||
u.set_build_target(&entry.source, &entry.version.full(), &entry.distribution);
|
package: &entry.source,
|
||||||
}
|
version: &entry.version.full(),
|
||||||
|
target: &entry.distribution,
|
||||||
|
display: format!(
|
||||||
|
"Building source package {} ({}) for {}",
|
||||||
|
entry.source,
|
||||||
|
entry.version.full(),
|
||||||
|
entry.distribution
|
||||||
|
),
|
||||||
|
source_only: true,
|
||||||
|
tee_log: true,
|
||||||
|
});
|
||||||
|
|
||||||
let source_display = entry.source.clone();
|
let source_display = entry.source.clone();
|
||||||
|
|
||||||
@@ -357,9 +375,7 @@ pub fn run_source_build(
|
|||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// 5. dpkg-source lifecycle: before-build + source build
|
// 5. dpkg-source lifecycle: before-build + source build
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
if let Some(u) = &ui {
|
view.phase("Applying patches", Box::new(DpkgSourceClassifier::new()));
|
||||||
u.phase_custom("Applying patches", Box::new(DpkgSourceClassifier::new()));
|
|
||||||
}
|
|
||||||
run_command(
|
run_command(
|
||||||
cwd,
|
cwd,
|
||||||
"dpkg-source",
|
"dpkg-source",
|
||||||
@@ -372,9 +388,7 @@ pub fn run_source_build(
|
|||||||
// dpkg-buildpackage skips it entirely for source-only builds unless
|
// dpkg-buildpackage skips it entirely for source-only builds unless
|
||||||
// forced with -D; unsatisfied dependencies abort with exit status 3.
|
// forced with -D; unsatisfied dependencies abort with exit status 3.
|
||||||
if opts.force_dep_check {
|
if opts.force_dep_check {
|
||||||
if let Some(u) = &ui {
|
view.message("Checking build dependencies");
|
||||||
u.progress_message("Checking build dependencies");
|
|
||||||
}
|
|
||||||
let check_opts = crate::debian::deps::CheckOpts {
|
let check_opts = crate::debian::deps::CheckOpts {
|
||||||
host_arch: arch_vars
|
host_arch: arch_vars
|
||||||
.get("DEB_HOST_ARCH")
|
.get("DEB_HOST_ARCH")
|
||||||
@@ -389,19 +403,19 @@ pub fn run_source_build(
|
|||||||
};
|
};
|
||||||
let report = crate::debian::deps::check_build_depends(&ctrl, &check_opts)?;
|
let report = crate::debian::deps::check_build_depends(&ctrl, &check_opts)?;
|
||||||
if !report.is_ok() {
|
if !report.is_ok() {
|
||||||
eprintln!("{}", report.message());
|
// The typed error carries the diagnostics (UnmetReport is
|
||||||
|
// public); the caller renders them and maps the type to exit
|
||||||
|
// status 3, like dpkg-buildpackage does.
|
||||||
return Err(Box::new(crate::debian::deps::UnmetBuildDependencies(
|
return Err(Box::new(crate::debian::deps::UnmetBuildDependencies(
|
||||||
report,
|
report,
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(u) = &ui {
|
view.phase(
|
||||||
u.phase_custom(
|
"Building source package",
|
||||||
"Building source package",
|
Box::new(DpkgSourceClassifier::new()),
|
||||||
Box::new(DpkgSourceClassifier::new()),
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Err(failure) = run_command_capturing(
|
if let Err(failure) = run_command_capturing(
|
||||||
cwd,
|
cwd,
|
||||||
"dpkg-source",
|
"dpkg-source",
|
||||||
@@ -427,9 +441,7 @@ pub fn run_source_build(
|
|||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// 6. .buildinfo generation (native dpkg-genbuildinfo equivalent)
|
// 6. .buildinfo generation (native dpkg-genbuildinfo equivalent)
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
if let Some(u) = &ui {
|
view.message("Generating .buildinfo");
|
||||||
u.progress_message("Generating .buildinfo");
|
|
||||||
}
|
|
||||||
// What the .buildinfo itself records: like dpkg-genbuildinfo, only the
|
// What the .buildinfo itself records: like dpkg-genbuildinfo, only the
|
||||||
// referenced .dsc — not the tarballs, and never the buildinfo itself.
|
// referenced .dsc — not the tarballs, and never the buildinfo itself.
|
||||||
let mut buildinfo_checksums = FileChecksums::new();
|
let mut buildinfo_checksums = FileChecksums::new();
|
||||||
@@ -479,9 +491,7 @@ pub fn run_source_build(
|
|||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// 7. .changes generation (native dpkg-genchanges equivalent)
|
// 7. .changes generation (native dpkg-genchanges equivalent)
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
if let Some(u) = &ui {
|
view.message("Generating .changes");
|
||||||
u.progress_message("Generating .changes");
|
|
||||||
}
|
|
||||||
// What the .changes distributes: the .dsc (with its recorded digests),
|
// What the .changes distributes: the .dsc (with its recorded digests),
|
||||||
// the tarballs listed in it (below), and the .buildinfo (last, as
|
// the tarballs listed in it (below), and the .buildinfo (last, as
|
||||||
// dpkg-genchanges does when it consumes debian/files).
|
// dpkg-genchanges does when it consumes debian/files).
|
||||||
@@ -538,13 +548,11 @@ pub fn run_source_build(
|
|||||||
if opts.orig_source == OrigSourceMode::Never && !has_debian_part {
|
if opts.orig_source == OrigSourceMode::Never && !has_debian_part {
|
||||||
log::warn!("ignoring --orig never for a native Debian package");
|
log::warn!("ignoring --orig never for a native Debian package");
|
||||||
}
|
}
|
||||||
if let Some(u) = &ui {
|
view.message(if strip_origs {
|
||||||
u.progress_message(if strip_origs {
|
"Not including original source code in upload"
|
||||||
"Not including original source code in upload"
|
} else {
|
||||||
} else {
|
"Including full source code in upload"
|
||||||
"Including full source code in upload"
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
// Stripped orig tarballs (and their detached .asc signatures) are not
|
// Stripped orig tarballs (and their detached .asc signatures) are not
|
||||||
// distributed at all: not hashed, not required on disk, like
|
// distributed at all: not hashed, not required on disk, like
|
||||||
// dpkg-genchanges.
|
// dpkg-genchanges.
|
||||||
@@ -634,9 +642,7 @@ pub fn run_source_build(
|
|||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// 8. dpkg-source after-build (unapplies quilt patches it applied)
|
// 8. dpkg-source after-build (unapplies quilt patches it applied)
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
if let Some(u) = &ui {
|
view.phase("Restoring patches", Box::new(DpkgSourceClassifier::new()));
|
||||||
u.phase_custom("Restoring patches", Box::new(DpkgSourceClassifier::new()));
|
|
||||||
}
|
|
||||||
run_command(
|
run_command(
|
||||||
cwd,
|
cwd,
|
||||||
"dpkg-source",
|
"dpkg-source",
|
||||||
@@ -652,9 +658,7 @@ pub fn run_source_build(
|
|||||||
if let Some(keyid) = signing_key.filter(|_| do_sign) {
|
if let Some(keyid) = signing_key.filter(|_| do_sign) {
|
||||||
crate::utils::gpg::validate_key_id(&keyid)?;
|
crate::utils::gpg::validate_key_id(&keyid)?;
|
||||||
|
|
||||||
if let Some(u) = &ui {
|
view.phase("Signing artifacts", Box::new(GenericClassifier::new()));
|
||||||
u.phase_custom("Signing artifacts", Box::new(GenericClassifier::new()));
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("Signing {}", dsc_name);
|
log::info!("Signing {}", dsc_name);
|
||||||
crate::utils::gpg::clearsign_file(&dsc_path, &keyid)?;
|
crate::utils::gpg::clearsign_file(&dsc_path, &keyid)?;
|
||||||
@@ -1039,7 +1043,7 @@ mod tests {
|
|||||||
.expect("write control");
|
.expect("write control");
|
||||||
std::fs::write(tree.join("debian/rules"), "#!/usr/bin/make -f\n").expect("write rules");
|
std::fs::write(tree.join("debian/rules"), "#!/usr/bin/make -f\n").expect("write rules");
|
||||||
|
|
||||||
let err = run_source_build(&tree, &SourceBuildOptions::default(), None)
|
let err = run_source_build(&tree, &SourceBuildOptions::default(), &crate::report::Quiet)
|
||||||
.expect_err("binary-only entries must not build a source package");
|
.expect_err("binary-only entries must not build a source package");
|
||||||
let err = err.to_string();
|
let err = err.to_string();
|
||||||
assert!(err.contains("binary-only"), "{err}");
|
assert!(err.contains("binary-only"), "{err}");
|
||||||
@@ -1575,7 +1579,8 @@ mod differential_tests {
|
|||||||
OrigSourceMode::Never => &["-sd"],
|
OrigSourceMode::Never => &["-sd"],
|
||||||
};
|
};
|
||||||
run_dpkg(&golden_tree, source_style);
|
run_dpkg(&golden_tree, source_style);
|
||||||
run_source_build(&ours_tree, opts, None).expect("native source pipeline failed");
|
run_source_build(&ours_tree, opts, &crate::report::Quiet)
|
||||||
|
.expect("native source pipeline failed");
|
||||||
|
|
||||||
let entry =
|
let entry =
|
||||||
crate::debian::parse_changelog_entry(&ours_tree.join("debian/changelog")).unwrap();
|
crate::debian::parse_changelog_entry(&ours_tree.join("debian/changelog")).unwrap();
|
||||||
@@ -2160,7 +2165,7 @@ Provides: virtual-thing (= 2.0), plain-virtual
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Ours: the native pipeline refuses likewise.
|
// Ours: the native pipeline refuses likewise.
|
||||||
let err = run_source_build(&tree, &SourceBuildOptions::default(), None)
|
let err = run_source_build(&tree, &SourceBuildOptions::default(), &crate::report::Quiet)
|
||||||
.expect_err("native source build must refuse a binary-only entry");
|
.expect_err("native source build must refuse a binary-only entry");
|
||||||
assert!(err.to_string().contains("binary-only"), "{err}");
|
assert!(err.to_string().contains("binary-only"), "{err}");
|
||||||
}
|
}
|
||||||
|
|||||||
+174
-7
@@ -5,13 +5,30 @@ use std::fs::File;
|
|||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Outcome of a successful [`generate_entry`] call: everything the CLI
|
||||||
|
/// renders for the user, and everything a library consumer needs to chain
|
||||||
|
/// further steps.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct GeneratedEntry {
|
||||||
|
/// Source package name from the previous changelog entry.
|
||||||
|
pub package: String,
|
||||||
|
/// Version the changelog carried before the new entry was prepended.
|
||||||
|
pub previous_version: String,
|
||||||
|
/// Version of the freshly added entry.
|
||||||
|
pub new_version: String,
|
||||||
|
/// Distribution series the new entry targets.
|
||||||
|
pub series: String,
|
||||||
|
/// The changelog file that was updated.
|
||||||
|
pub path: std::path::PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
/// Automatically generate a changelog entry from a commit history and previous changelog
|
/// Automatically generate a changelog entry from a commit history and previous changelog
|
||||||
pub fn generate_entry(
|
pub fn generate_entry(
|
||||||
changelog_file: &str,
|
changelog_file: &str,
|
||||||
cwd: Option<&Path>,
|
cwd: Option<&Path>,
|
||||||
user_version: Option<&str>,
|
user_version: Option<&str>,
|
||||||
target_series: Option<&str>,
|
target_series: Option<&str>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<GeneratedEntry, Box<dyn std::error::Error>> {
|
||||||
let changelog_path = if let Some(path) = cwd {
|
let changelog_path = if let Some(path) = cwd {
|
||||||
path.join(changelog_file)
|
path.join(changelog_file)
|
||||||
} else {
|
} else {
|
||||||
@@ -19,8 +36,7 @@ pub fn generate_entry(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Parse existing changelog to get current (old) version
|
// Parse existing changelog to get current (old) version
|
||||||
let (package, old_version, series) = parse_changelog_header(&changelog_path)?;
|
let (package, old_version, current_series) = parse_changelog_header(&changelog_path)?;
|
||||||
println!("Found package: {}, version: {}", package, old_version);
|
|
||||||
|
|
||||||
// Open git repo, and find commits since last version tag
|
// Open git repo, and find commits since last version tag
|
||||||
let repo_path = if let Some(path) = cwd {
|
let repo_path = if let Some(path) = cwd {
|
||||||
@@ -44,7 +60,7 @@ pub fn generate_entry(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (maintainer_name, maintainer_email) = get_maintainer_info()?;
|
let (maintainer_name, maintainer_email) = get_maintainer_info()?;
|
||||||
let series = target_series.unwrap_or(&series).to_string();
|
let series = target_series.unwrap_or(¤t_series).to_string();
|
||||||
let new_entry = format_entry(
|
let new_entry = format_entry(
|
||||||
&package,
|
&package,
|
||||||
&new_version,
|
&new_version,
|
||||||
@@ -56,9 +72,13 @@ pub fn generate_entry(
|
|||||||
|
|
||||||
prepend_to_file(&changelog_path, &new_entry)?;
|
prepend_to_file(&changelog_path, &new_entry)?;
|
||||||
|
|
||||||
println!("Added new changelog entry to {}", changelog_path.display());
|
Ok(GeneratedEntry {
|
||||||
|
package,
|
||||||
Ok(())
|
previous_version: old_version,
|
||||||
|
new_version,
|
||||||
|
series,
|
||||||
|
path: changelog_path,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute the next (most probable) version number of a package, from old version and
|
/// Compute the next (most probable) version number of a package, from old version and
|
||||||
@@ -139,6 +159,77 @@ pub fn parse_changelog_header(
|
|||||||
Ok((entry.source, entry.version.full(), entry.distribution))
|
Ok((entry.source, entry.version.full(), entry.distribution))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a new changelog entry may target as series, derived from the
|
||||||
|
/// current changelog ([`series_candidates`]).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum SeriesCandidates {
|
||||||
|
/// Offer `options` with `default` preselected; when the selection
|
||||||
|
/// cannot be made (cancelled, no interactive user) `fallback` — the
|
||||||
|
/// changelog's current series — is used instead.
|
||||||
|
Choose {
|
||||||
|
/// Series names to offer.
|
||||||
|
options: Vec<String>,
|
||||||
|
/// Preselected series.
|
||||||
|
default: String,
|
||||||
|
/// Series to fall back to when nothing can be selected.
|
||||||
|
fallback: String,
|
||||||
|
},
|
||||||
|
/// Nothing to choose: the series list was unavailable, keep the
|
||||||
|
/// current series.
|
||||||
|
Keep(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive the candidate series for a new changelog entry from the changelog
|
||||||
|
/// at `changelog_path`.
|
||||||
|
///
|
||||||
|
/// An UNRELEASED entry offers itself as a pinned first option (selecting it
|
||||||
|
/// keeps the changelog unreleased) on top of the current vendor's series
|
||||||
|
/// list, defaulting to the development series; any other series resolves
|
||||||
|
/// through the series list of its own distribution. `None` when the
|
||||||
|
/// changelog cannot be parsed (no default to derive at all).
|
||||||
|
pub async fn series_candidates(changelog_path: &Path) -> Option<SeriesCandidates> {
|
||||||
|
let (_package, _version, current) = parse_changelog_header(changelog_path).ok()?;
|
||||||
|
|
||||||
|
if crate::distro_info::is_unreleased(¤t) {
|
||||||
|
// Vendors keep original casing ("Ubuntu"), while the series data
|
||||||
|
// keys are lowercase
|
||||||
|
let dist = crate::build::env::current_vendor().to_lowercase();
|
||||||
|
let mut options = vec![crate::distro_info::UNRELEASED.to_string()];
|
||||||
|
match crate::distro_info::get_ordered_series_name(&dist).await {
|
||||||
|
Ok(series_list) => {
|
||||||
|
options.extend(series_list);
|
||||||
|
// Default to the development series (the first real entry),
|
||||||
|
// not to the pinned UNRELEASED entry itself
|
||||||
|
let default = if options.len() > 1 {
|
||||||
|
options[1].clone()
|
||||||
|
} else {
|
||||||
|
current.clone()
|
||||||
|
};
|
||||||
|
Some(SeriesCandidates::Choose {
|
||||||
|
options,
|
||||||
|
default,
|
||||||
|
fallback: current,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(_) => Some(SeriesCandidates::Keep(current)),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match crate::distro_info::get_dist_from_series(¤t).await {
|
||||||
|
Ok(dist) => match crate::distro_info::get_ordered_series_name(&dist).await {
|
||||||
|
// Even an empty list goes through the selector: its
|
||||||
|
// fallback prints and takes the default, like it always has
|
||||||
|
Ok(options) => Some(SeriesCandidates::Choose {
|
||||||
|
options,
|
||||||
|
default: current.clone(),
|
||||||
|
fallback: current,
|
||||||
|
}),
|
||||||
|
Err(_) => Some(SeriesCandidates::Keep(current)),
|
||||||
|
},
|
||||||
|
Err(_) => Some(SeriesCandidates::Keep(current)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse a changelog file footer to extract maintainer information
|
/// Parse a changelog file footer to extract maintainer information
|
||||||
/// Returns (name, email) tuple from the last modification entry
|
/// Returns (name, email) tuple from the last modification entry
|
||||||
pub fn parse_changelog_footer(path: &Path) -> Result<(String, String), Box<dyn std::error::Error>> {
|
pub fn parse_changelog_footer(path: &Path) -> Result<(String, String), Box<dyn std::error::Error>> {
|
||||||
@@ -306,6 +397,82 @@ mod tests {
|
|||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
/// An UNRELEASED changelog offers UNRELEASED pinned first, the
|
||||||
|
/// development series as the default, and itself as the fallback.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn series_candidates_unreleased_pins_entry_and_defaults_to_dev() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("changelog");
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
"hello (1.0-1) UNRELEASED; urgency=medium\n\n * Something.\n\n \
|
||||||
|
-- A B <a@b.c> Mon, 01 Jan 2024 00:00:00 +0000\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
match series_candidates(&path).await {
|
||||||
|
Some(SeriesCandidates::Choose {
|
||||||
|
options,
|
||||||
|
default,
|
||||||
|
fallback,
|
||||||
|
}) => {
|
||||||
|
assert_eq!(options[0], "UNRELEASED");
|
||||||
|
assert!(options.len() > 1, "the vendor series list is offered");
|
||||||
|
assert_eq!(default, options[1]);
|
||||||
|
assert_eq!(fallback, "UNRELEASED");
|
||||||
|
}
|
||||||
|
other => panic!("expected Choose, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A released changelog offers its distribution's series with the
|
||||||
|
/// current one preselected. Uses a series of the host vendor so the
|
||||||
|
/// test only relies on the local distro-info data.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn series_candidates_released_defaults_to_current_series() {
|
||||||
|
let dist = crate::build::env::current_vendor().to_lowercase();
|
||||||
|
let vendor_series = crate::distro_info::get_ordered_series_name(&dist)
|
||||||
|
.await
|
||||||
|
.expect("the host vendor's series data resolves");
|
||||||
|
// Any released series of the vendor works; the changelog names it.
|
||||||
|
let current = vendor_series.last().expect("non-empty series list");
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("changelog");
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
format!(
|
||||||
|
"hello (1.0-1) {current}; urgency=medium\n\n * Something.\n\n \
|
||||||
|
-- A B <a@b.c> Mon, 01 Jan 2024 00:00:00 +0000\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
match series_candidates(&path).await {
|
||||||
|
Some(SeriesCandidates::Choose {
|
||||||
|
options,
|
||||||
|
default,
|
||||||
|
fallback,
|
||||||
|
}) => {
|
||||||
|
assert_eq!(options, vendor_series);
|
||||||
|
assert_eq!(default, *current);
|
||||||
|
assert_eq!(fallback, *current);
|
||||||
|
}
|
||||||
|
other => panic!("expected Choose, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Without a parsable changelog there is no candidate at all.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn series_candidates_none_without_changelog() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
assert!(
|
||||||
|
series_candidates(&dir.path().join("changelog"))
|
||||||
|
.await
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn setup_repo(dir: &Path) {
|
fn setup_repo(dir: &Path) {
|
||||||
Command::new("git")
|
Command::new("git")
|
||||||
.arg("init")
|
.arg("init")
|
||||||
|
|||||||
@@ -117,6 +117,28 @@ pub enum ContextConfig {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ContextConfig {
|
||||||
|
/// Build an SSH context configuration from an endpoint of the form
|
||||||
|
/// `[ssh://][user@]host[:port]`.
|
||||||
|
pub fn from_endpoint(endpoint: &str) -> Result<Self, String> {
|
||||||
|
let re = regex::Regex::new(
|
||||||
|
r"^(?:ssh://)?(?:(?P<user>[^@]+)@)?(?P<host>[^:/]+)(?::(?P<port>\d+))?$",
|
||||||
|
)
|
||||||
|
.expect("valid endpoint regex");
|
||||||
|
let cap = re.captures(endpoint).ok_or_else(|| {
|
||||||
|
format!("Invalid endpoint format: '{endpoint}'. Expected [ssh://][user@]host[:port]")
|
||||||
|
})?;
|
||||||
|
let host = cap.name("host").unwrap().as_str().to_string();
|
||||||
|
let user = cap.name("user").map(|m| m.as_str().to_string());
|
||||||
|
let port = cap
|
||||||
|
.name("port")
|
||||||
|
.map(|m| m.as_str().parse::<u16>())
|
||||||
|
.transpose()
|
||||||
|
.map_err(|_| "Invalid port number".to_string())?;
|
||||||
|
Ok(ContextConfig::Ssh { host, user, port })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A context, allowing to run commands, read and write files, etc
|
/// A context, allowing to run commands, read and write files, etc
|
||||||
pub struct Context {
|
pub struct Context {
|
||||||
/// Configuration for the context
|
/// Configuration for the context
|
||||||
@@ -455,3 +477,57 @@ fn contextualize_spawn_error(program: &str, e: io::Error) -> io::Error {
|
|||||||
io::Error::new(e.kind(), format!("Could not run '{program}': {e}"))
|
io::Error::new(e.kind(), format!("Could not run '{program}': {e}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod endpoint_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Every accepted endpoint spelling maps to the expected config.
|
||||||
|
#[test]
|
||||||
|
fn from_endpoint_parses_all_spellings() {
|
||||||
|
assert_eq!(
|
||||||
|
ContextConfig::from_endpoint("myhost"),
|
||||||
|
Ok(ContextConfig::Ssh {
|
||||||
|
host: "myhost".into(),
|
||||||
|
user: None,
|
||||||
|
port: None,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ContextConfig::from_endpoint("admin@myhost"),
|
||||||
|
Ok(ContextConfig::Ssh {
|
||||||
|
host: "myhost".into(),
|
||||||
|
user: Some("admin".into()),
|
||||||
|
port: None,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ContextConfig::from_endpoint("myhost:2222"),
|
||||||
|
Ok(ContextConfig::Ssh {
|
||||||
|
host: "myhost".into(),
|
||||||
|
user: None,
|
||||||
|
port: Some(2222),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ContextConfig::from_endpoint("ssh://admin@myhost:22"),
|
||||||
|
Ok(ContextConfig::Ssh {
|
||||||
|
host: "myhost".into(),
|
||||||
|
user: Some("admin".into()),
|
||||||
|
port: Some(22),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-numeric ports and extra segments are format errors; a
|
||||||
|
/// non-u16 numeric port is a port error.
|
||||||
|
#[test]
|
||||||
|
fn from_endpoint_rejects_malformed_endpoints() {
|
||||||
|
for bad in ["", "a/b/c", "host:notaport"] {
|
||||||
|
let err = ContextConfig::from_endpoint(bad).unwrap_err();
|
||||||
|
assert!(err.contains("Invalid endpoint format"), "{err}");
|
||||||
|
}
|
||||||
|
let err = ContextConfig::from_endpoint("host:99999").unwrap_err();
|
||||||
|
assert_eq!(err, "Invalid port number");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,6 +62,20 @@ pub fn setup_environment(
|
|||||||
.map_err(|e| format!("Invalid UTF-8 in dpkg-architecture output: {e}"))?;
|
.map_err(|e| format!("Invalid UTF-8 in dpkg-architecture output: {e}"))?;
|
||||||
parse_dpkg_architecture_output(&dpkg_architecture, env);
|
parse_dpkg_architecture_output(&dpkg_architecture, env);
|
||||||
|
|
||||||
|
// In-tree tools locate their libraries with the *host* pkg-config during
|
||||||
|
// cross builds (the kernel's tools/build feature checks derive their
|
||||||
|
// cflags/ldflags from `pkg-config --cflags/--libs`), whose search path
|
||||||
|
// only covers the build architecture's pkgconfig dirs. Point it at the
|
||||||
|
// target's so `libtraceevent` & co resolve to target-arch libraries:
|
||||||
|
// linux-riscv cross builds die in rtla's Makefile.config otherwise, even
|
||||||
|
// with the target -dev packages installed.
|
||||||
|
if let Some(multiarch) = env.get("DEB_HOST_MULTIARCH").cloned() {
|
||||||
|
env.insert(
|
||||||
|
"PKG_CONFIG_LIBDIR".to_string(),
|
||||||
|
format!("/usr/lib/{multiarch}/pkgconfig:/usr/share/pkgconfig"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
env.insert("DEB_BUILD_PROFILES".to_string(), "cross".to_string());
|
env.insert("DEB_BUILD_PROFILES".to_string(), "cross".to_string());
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -289,4 +303,25 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(cross_suites("noble", None, "not-a-distro").is_err());
|
assert!(cross_suites("noble", None, "not-a-distro").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// setup_environment exports the target multiarch pkg-config libdir:
|
||||||
|
/// tools' feature checks run the *host* pkg-config, which must find the
|
||||||
|
/// target's .pc files (rtla hard-errors on libtraceevent otherwise,
|
||||||
|
/// failing linux-riscv cross builds despite the target -dev packages
|
||||||
|
/// being installed).
|
||||||
|
#[test]
|
||||||
|
fn test_setup_environment_exports_cross_pkg_config_libdir() {
|
||||||
|
let mut env = HashMap::new();
|
||||||
|
let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap());
|
||||||
|
setup_environment(&mut env, "riscv64", ctx).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
env.get("PKG_CONFIG_LIBDIR").map(String::as_str),
|
||||||
|
Some("/usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
env.get("DEB_BUILD_PROFILES").map(String::as_str),
|
||||||
|
Some("cross")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-26
@@ -1,5 +1,6 @@
|
|||||||
use crate::context::{self, Context, ContextConfig};
|
use crate::context::{self, Context, ContextConfig};
|
||||||
use crate::ui::deb::{DebUi, Phase};
|
use crate::deb::{Phase, enter_phase};
|
||||||
|
use crate::report::BuildView;
|
||||||
use directories::ProjectDirs;
|
use directories::ProjectDirs;
|
||||||
use std::any::Any;
|
use std::any::Any;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
@@ -312,7 +313,7 @@ impl EphemeralContextGuard {
|
|||||||
series: &str,
|
series: &str,
|
||||||
arch: Option<&str>,
|
arch: Option<&str>,
|
||||||
base_ctx: Arc<Context>,
|
base_ctx: Arc<Context>,
|
||||||
ui: Option<Arc<DebUi>>,
|
view: &dyn BuildView,
|
||||||
) -> Result<Self, Box<dyn Error>> {
|
) -> Result<Self, Box<dyn Error>> {
|
||||||
// Save the globally-installed context so Drop can restore exactly
|
// Save the globally-installed context so Drop can restore exactly
|
||||||
// this handle: concurrent builds install their own ephemeral
|
// this handle: concurrent builds install their own ephemeral
|
||||||
@@ -355,7 +356,7 @@ impl EphemeralContextGuard {
|
|||||||
|
|
||||||
// Download and extract the chroot tarball
|
// Download and extract the chroot tarball
|
||||||
if let Err(e) =
|
if let Err(e) =
|
||||||
Self::download_and_extract_chroot(series, arch, &chroot_path, base_ctx.clone(), &ui)
|
Self::download_and_extract_chroot(series, arch, &chroot_path, base_ctx.clone(), view)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
// The guard (and its Drop) never materializes on this path, so
|
// The guard (and its Drop) never materializes on this path, so
|
||||||
@@ -408,7 +409,7 @@ impl EphemeralContextGuard {
|
|||||||
arch: Option<&str>,
|
arch: Option<&str>,
|
||||||
chroot_path: &PathBuf,
|
chroot_path: &PathBuf,
|
||||||
ctx: Arc<context::Context>,
|
ctx: Arc<context::Context>,
|
||||||
ui: &Option<Arc<DebUi>>,
|
view: &dyn BuildView,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
// Clone ctx for use in create_device_nodes after download_chroot_tarball consumes it
|
// Clone ctx for use in create_device_nodes after download_chroot_tarball consumes it
|
||||||
let ctx_for_devices = ctx.clone();
|
let ctx_for_devices = ctx.clone();
|
||||||
@@ -464,10 +465,8 @@ impl EphemeralContextGuard {
|
|||||||
series,
|
series,
|
||||||
arch
|
arch
|
||||||
);
|
);
|
||||||
if let Some(u) = ui {
|
enter_phase(view, Phase::PreparingChroot);
|
||||||
u.phase(Phase::PreparingChroot);
|
Self::download_chroot_tarball(series, arch, &tarball_path, ctx, view).await?;
|
||||||
}
|
|
||||||
Self::download_chroot_tarball(series, arch, &tarball_path, ctx, ui).await?;
|
|
||||||
} else {
|
} else {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"Using cached chroot tarball for {} (arch: {:?})",
|
"Using cached chroot tarball for {} (arch: {:?})",
|
||||||
@@ -478,16 +477,12 @@ impl EphemeralContextGuard {
|
|||||||
|
|
||||||
// Extract tarball to chroot directory
|
// Extract tarball to chroot directory
|
||||||
log::debug!("Extracting chroot tarball to {}...", chroot_path.display());
|
log::debug!("Extracting chroot tarball to {}...", chroot_path.display());
|
||||||
if let Some(u) = ui {
|
enter_phase(view, Phase::ExtractingChroot);
|
||||||
u.phase(Phase::ExtractingChroot);
|
Self::extract_tarball(&tarball_path, chroot_path, view)?;
|
||||||
}
|
|
||||||
Self::extract_tarball(&tarball_path, chroot_path, ui.as_deref())?;
|
|
||||||
|
|
||||||
// Create device nodes in the chroot
|
// Create device nodes in the chroot
|
||||||
log::debug!("Creating device nodes in chroot...");
|
log::debug!("Creating device nodes in chroot...");
|
||||||
if let Some(u) = ui {
|
enter_phase(view, Phase::FinalizingChroot);
|
||||||
u.phase(Phase::FinalizingChroot);
|
|
||||||
}
|
|
||||||
Self::create_device_nodes(chroot_path, ctx_for_devices.clone())?;
|
Self::create_device_nodes(chroot_path, ctx_for_devices.clone())?;
|
||||||
|
|
||||||
// Bind mount /proc from host into chroot (before entering unshare namespace)
|
// Bind mount /proc from host into chroot (before entering unshare namespace)
|
||||||
@@ -503,7 +498,7 @@ impl EphemeralContextGuard {
|
|||||||
arch: Option<&str>,
|
arch: Option<&str>,
|
||||||
tarball_path: &Path,
|
tarball_path: &Path,
|
||||||
ctx: Arc<context::Context>,
|
ctx: Arc<context::Context>,
|
||||||
ui: &Option<Arc<DebUi>>,
|
view: &dyn BuildView,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
// Create a lock file to make sure that noone tries to use the file while it's not fully downloaded
|
// Create a lock file to make sure that noone tries to use the file while it's not fully downloaded
|
||||||
let lockfile_path = tarball_path.with_extension("lock");
|
let lockfile_path = tarball_path.with_extension("lock");
|
||||||
@@ -537,8 +532,8 @@ impl EphemeralContextGuard {
|
|||||||
cmd.arg(series)
|
cmd.arg(series)
|
||||||
.arg(tarball_path.to_string_lossy().to_string());
|
.arg(tarball_path.to_string_lossy().to_string());
|
||||||
|
|
||||||
if let Some(u) = ui {
|
if let Some(s) = view.sink() {
|
||||||
cmd.capture(u.sink());
|
cmd.capture(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
let status = cmd.status()?;
|
let status = cmd.status()?;
|
||||||
@@ -575,7 +570,7 @@ impl EphemeralContextGuard {
|
|||||||
fn extract_tarball(
|
fn extract_tarball(
|
||||||
tarball_path: &PathBuf,
|
tarball_path: &PathBuf,
|
||||||
chroot_path: &PathBuf,
|
chroot_path: &PathBuf,
|
||||||
ui: Option<&DebUi>,
|
view: &dyn BuildView,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
// Create the chroot directory
|
// Create the chroot directory
|
||||||
fs::create_dir_all(chroot_path)?;
|
fs::create_dir_all(chroot_path)?;
|
||||||
@@ -593,15 +588,11 @@ impl EphemeralContextGuard {
|
|||||||
let mut entry = entry?;
|
let mut entry = entry?;
|
||||||
entry.unpack_in(chroot_path)?;
|
entry.unpack_in(chroot_path)?;
|
||||||
count += 1;
|
count += 1;
|
||||||
if count.is_multiple_of(100)
|
if count.is_multiple_of(100) {
|
||||||
&& let Some(u) = ui
|
view.message(&format!("Extracting chroot… ({count} files)"));
|
||||||
{
|
|
||||||
u.progress_message(&format!("Extracting chroot… ({count} files)"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(u) = ui {
|
view.message(&format!("Extracting chroot… ({count} files)"));
|
||||||
u.progress_message(&format!("Extracting chroot… ({count} files)"));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-91
@@ -1,9 +1,9 @@
|
|||||||
/// Local binary package building
|
/// Local binary package building
|
||||||
/// Directly calling 'debian/rules' in current context
|
/// Directly calling 'debian/rules' in current context
|
||||||
use crate::context::{Context, ContextCommand, LineSink};
|
use crate::context::{Context, ContextCommand, LineSink};
|
||||||
use crate::deb::find_dsc_file;
|
use crate::deb::{Phase, enter_phase, find_dsc_file};
|
||||||
use crate::ui::deb::{DebUi, Phase};
|
use crate::logfmt::QuiltClassifier;
|
||||||
use crate::ui::logfmt::QuiltClassifier;
|
use crate::report::BuildView;
|
||||||
use log::warn;
|
use log::warn;
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
@@ -33,13 +33,13 @@ pub async fn build(
|
|||||||
pocket: Option<&str>,
|
pocket: Option<&str>,
|
||||||
build_root: &str,
|
build_root: &str,
|
||||||
cross: bool,
|
cross: bool,
|
||||||
ppa: Option<&[&str]>,
|
ppa: &[String],
|
||||||
inject_packages: Option<&[&str]>,
|
inject_packages: &[String],
|
||||||
ctx: Arc<Context>,
|
ctx: Arc<Context>,
|
||||||
ui: Option<Arc<DebUi>>,
|
view: &dyn BuildView,
|
||||||
jobs: Option<usize>,
|
jobs: Option<usize>,
|
||||||
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
|
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
|
||||||
let sink: Option<Arc<dyn LineSink>> = ui.as_ref().map(|u| u.sink());
|
let sink: Option<Arc<dyn LineSink>> = view.sink();
|
||||||
|
|
||||||
// Environment
|
// Environment
|
||||||
let mut env = HashMap::<String, String>::new();
|
let mut env = HashMap::<String, String>::new();
|
||||||
@@ -83,55 +83,46 @@ pub async fn build(
|
|||||||
let mut added_ppas: Vec<(&str, &str)> = Vec::new();
|
let mut added_ppas: Vec<(&str, &str)> = Vec::new();
|
||||||
|
|
||||||
// Add PPA repositories if specified
|
// Add PPA repositories if specified
|
||||||
if let Some(ppas) = ppa {
|
for ppa_str in ppa {
|
||||||
for ppa_str in ppas {
|
let (ppa_user, ppa_name) = crate::package_info::split_ppa(ppa_str)?;
|
||||||
// PPA format: user/ppa_name
|
let base_url = crate::package_info::ppa_to_base_url(ppa_user, ppa_name);
|
||||||
let parts: Vec<&str> = ppa_str.split('/').collect();
|
|
||||||
if parts.len() == 2 {
|
|
||||||
let base_url = crate::package_info::ppa_to_base_url(parts[0], parts[1]);
|
|
||||||
|
|
||||||
// Add new PPA source if not found
|
// Add new PPA source if not found
|
||||||
if !sources.iter().any(|s| s.uri.contains(&base_url)) {
|
if !sources.iter().any(|s| s.uri.contains(&base_url)) {
|
||||||
// Get host and target architectures
|
// Get host and target architectures
|
||||||
let host_arch = crate::get_current_arch();
|
let host_arch = crate::get_current_arch();
|
||||||
let target_arch = arch;
|
let target_arch = arch;
|
||||||
|
|
||||||
// Create architectures list with both host and target if different
|
// Create architectures list with both host and target if different
|
||||||
let mut architectures = vec![host_arch.clone()];
|
let mut architectures = vec![host_arch.clone()];
|
||||||
if host_arch != *target_arch {
|
if host_arch != *target_arch {
|
||||||
architectures.push(target_arch.to_string());
|
architectures.push(target_arch.to_string());
|
||||||
}
|
|
||||||
|
|
||||||
// Create suite list with all Ubuntu series
|
|
||||||
let suites = vec![series.to_string()];
|
|
||||||
|
|
||||||
let new_source = crate::apt::sources::SourceEntry {
|
|
||||||
enabled: true,
|
|
||||||
kind: crate::apt::sources::SourceKind::Deb,
|
|
||||||
components: vec!["main".to_string()],
|
|
||||||
architectures: architectures.clone(),
|
|
||||||
signed_by: None,
|
|
||||||
trusted: None,
|
|
||||||
suite: suites,
|
|
||||||
uri: base_url,
|
|
||||||
// No origin: saved to the pkh-owned added-sources file
|
|
||||||
origin: None,
|
|
||||||
};
|
|
||||||
sources.push(new_source);
|
|
||||||
modified = true;
|
|
||||||
added_ppas.push((parts[0], parts[1]));
|
|
||||||
log::info!(
|
|
||||||
"Added PPA: {} for series {} with architectures {:?}",
|
|
||||||
ppa_str,
|
|
||||||
series,
|
|
||||||
architectures
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return Err(
|
|
||||||
format!("Invalid PPA format: '{}'. Expected: user/ppa_name", ppa_str).into(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create suite list with all Ubuntu series
|
||||||
|
let suites = vec![series.to_string()];
|
||||||
|
|
||||||
|
let new_source = crate::apt::sources::SourceEntry {
|
||||||
|
enabled: true,
|
||||||
|
kind: crate::apt::sources::SourceKind::Deb,
|
||||||
|
components: vec!["main".to_string()],
|
||||||
|
architectures: architectures.clone(),
|
||||||
|
signed_by: None,
|
||||||
|
trusted: None,
|
||||||
|
suite: suites,
|
||||||
|
uri: base_url,
|
||||||
|
// No origin: saved to the pkh-owned added-sources file
|
||||||
|
origin: None,
|
||||||
|
};
|
||||||
|
sources.push(new_source);
|
||||||
|
modified = true;
|
||||||
|
added_ppas.push((ppa_user, ppa_name));
|
||||||
|
log::info!(
|
||||||
|
"Added PPA: {} for series {} with architectures {:?}",
|
||||||
|
ppa_str,
|
||||||
|
series,
|
||||||
|
architectures
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,9 +184,7 @@ pub async fn build(
|
|||||||
|
|
||||||
// Update package lists
|
// Update package lists
|
||||||
log::debug!("Updating package lists for local build...");
|
log::debug!("Updating package lists for local build...");
|
||||||
if let Some(u) = &ui {
|
enter_phase(view, Phase::UpdatingPackageLists);
|
||||||
u.phase(Phase::UpdatingPackageLists);
|
|
||||||
}
|
|
||||||
let status = cap(
|
let status = cap(
|
||||||
ctx.command("apt-get").envs(env.clone()).arg("update"),
|
ctx.command("apt-get").envs(env.clone()).arg("update"),
|
||||||
&sink,
|
&sink,
|
||||||
@@ -234,9 +223,7 @@ pub async fn build(
|
|||||||
cmd.arg(format!("libc6:{arch}"));
|
cmd.arg(format!("libc6:{arch}"));
|
||||||
cmd.arg(format!("libc6-dev:{arch}"));
|
cmd.arg(format!("libc6-dev:{arch}"));
|
||||||
}
|
}
|
||||||
if let Some(u) = &ui {
|
enter_phase(view, Phase::InstallingEssentials);
|
||||||
u.phase(Phase::InstallingEssentials);
|
|
||||||
}
|
|
||||||
let status = cap(&mut cmd, &sink).status()?;
|
let status = cap(&mut cmd, &sink).status()?;
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err("Could not install essential packages for the build".into());
|
return Err("Could not install essential packages for the build".into());
|
||||||
@@ -261,18 +248,16 @@ pub async fn build(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply quilt patches if the package provides a patch series
|
// Apply quilt patches if the package provides a patch series
|
||||||
apply_quilt_patches(package_dir_str, &env, ctx.clone(), &ui, &sink)?;
|
apply_quilt_patches(package_dir_str, &env, ctx.clone(), view, &sink)?;
|
||||||
|
|
||||||
// Install injected packages if specified
|
// Install injected packages if specified
|
||||||
if let Some(packages) = inject_packages {
|
if !inject_packages.is_empty() {
|
||||||
install_injected_packages(packages, &env, ctx.clone(), &ui, &sink)?;
|
install_injected_packages(inject_packages, &env, ctx.clone(), view, &sink)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install arch-specific build dependencies
|
// Install arch-specific build dependencies
|
||||||
log::debug!("Installing arch-specific build dependencies...");
|
log::debug!("Installing arch-specific build dependencies...");
|
||||||
if let Some(u) = &ui {
|
enter_phase(view, Phase::InstallingBuildDeps);
|
||||||
u.phase(Phase::InstallingBuildDeps);
|
|
||||||
}
|
|
||||||
let mut cmd = ctx.command("apt-get");
|
let mut cmd = ctx.command("apt-get");
|
||||||
cmd.current_dir(package_dir_str)
|
cmd.current_dir(package_dir_str)
|
||||||
.envs(env.clone())
|
.envs(env.clone())
|
||||||
@@ -286,9 +271,7 @@ pub async fn build(
|
|||||||
|
|
||||||
// If build-dep fails, we try to explain the failure using dose-debcheck
|
// If build-dep fails, we try to explain the failure using dose-debcheck
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
if let Some(u) = &ui {
|
view.suspend();
|
||||||
u.suspend();
|
|
||||||
}
|
|
||||||
dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone())?;
|
dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone())?;
|
||||||
return Err("Could not install build-dependencies for the build".into());
|
return Err("Could not install build-dependencies for the build".into());
|
||||||
}
|
}
|
||||||
@@ -320,9 +303,7 @@ pub async fn build(
|
|||||||
|
|
||||||
// If build-dep fails, we try to explain the failure using dose-debcheck
|
// If build-dep fails, we try to explain the failure using dose-debcheck
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
if let Some(u) = &ui {
|
view.suspend();
|
||||||
u.suspend();
|
|
||||||
}
|
|
||||||
dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone())?;
|
dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone())?;
|
||||||
return Err("Could not install build-dependencies for the build".into());
|
return Err("Could not install build-dependencies for the build".into());
|
||||||
}
|
}
|
||||||
@@ -330,9 +311,7 @@ pub async fn build(
|
|||||||
|
|
||||||
// Run the build step
|
// Run the build step
|
||||||
log::debug!("Building (debian/rules build) package...");
|
log::debug!("Building (debian/rules build) package...");
|
||||||
if let Some(u) = &ui {
|
enter_phase(view, Phase::Building);
|
||||||
u.phase(Phase::Building);
|
|
||||||
}
|
|
||||||
let status = cap(
|
let status = cap(
|
||||||
ctx.command("debian/rules")
|
ctx.command("debian/rules")
|
||||||
.current_dir(package_dir_str)
|
.current_dir(package_dir_str)
|
||||||
@@ -346,9 +325,7 @@ pub async fn build(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run the 'binary' step to produce deb
|
// Run the 'binary' step to produce deb
|
||||||
if let Some(u) = &ui {
|
enter_phase(view, Phase::ProducingBinaries);
|
||||||
u.phase(Phase::ProducingBinaries);
|
|
||||||
}
|
|
||||||
let status = cap(
|
let status = cap(
|
||||||
ctx.command("fakeroot")
|
ctx.command("fakeroot")
|
||||||
.current_dir(package_dir_str)
|
.current_dir(package_dir_str)
|
||||||
@@ -495,7 +472,7 @@ fn apply_quilt_patches(
|
|||||||
package_dir: &str,
|
package_dir: &str,
|
||||||
env: &HashMap<String, String>,
|
env: &HashMap<String, String>,
|
||||||
ctx: Arc<Context>,
|
ctx: Arc<Context>,
|
||||||
ui: &Option<Arc<DebUi>>,
|
view: &dyn BuildView,
|
||||||
sink: &Option<Arc<dyn LineSink>>,
|
sink: &Option<Arc<dyn LineSink>>,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
let series_path = Path::new(package_dir).join("debian/patches/series");
|
let series_path = Path::new(package_dir).join("debian/patches/series");
|
||||||
@@ -555,12 +532,10 @@ fn apply_quilt_patches(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply all patches listed in the series
|
// Apply all patches listed in the series
|
||||||
if let Some(u) = ui {
|
view.phase(
|
||||||
u.phase_with(
|
Phase::ApplyingPatches.label(),
|
||||||
Phase::ApplyingPatches,
|
Box::new(QuiltClassifier::new(total_patches)),
|
||||||
Box::new(QuiltClassifier::new(total_patches)),
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
let mut patch_env = env.clone();
|
let mut patch_env = env.clone();
|
||||||
patch_env.insert("QUILT_PATCHES".to_string(), "debian/patches".to_string());
|
patch_env.insert("QUILT_PATCHES".to_string(), "debian/patches".to_string());
|
||||||
let status = cap(
|
let status = cap(
|
||||||
@@ -631,17 +606,15 @@ fn pin_pocket(pocket_suite: &str, ctx: &Arc<Context>) -> Result<(), Box<dyn Erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn install_injected_packages(
|
fn install_injected_packages(
|
||||||
packages: &[&str],
|
packages: &[String],
|
||||||
env: &HashMap<String, String>,
|
env: &HashMap<String, String>,
|
||||||
ctx: Arc<Context>,
|
ctx: Arc<Context>,
|
||||||
ui: &Option<Arc<DebUi>>,
|
view: &dyn BuildView,
|
||||||
sink: &Option<Arc<dyn LineSink>>,
|
sink: &Option<Arc<dyn LineSink>>,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
log::info!("Installing injected packages: {:?}", packages);
|
log::info!("Installing injected packages: {:?}", packages);
|
||||||
|
|
||||||
if let Some(u) = ui {
|
enter_phase(view, Phase::InjectingPackages);
|
||||||
u.phase(Phase::InjectingPackages);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Separate .deb files from package names
|
// Separate .deb files from package names
|
||||||
let mut deb_files: Vec<String> = Vec::new();
|
let mut deb_files: Vec<String> = Vec::new();
|
||||||
@@ -661,7 +634,7 @@ fn install_injected_packages(
|
|||||||
);
|
);
|
||||||
deb_files.push(chroot_path.to_string_lossy().to_string());
|
deb_files.push(chroot_path.to_string_lossy().to_string());
|
||||||
} else {
|
} else {
|
||||||
package_names.push(pkg);
|
package_names.push(pkg.as_str());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -855,6 +828,13 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
apply_quilt_patches(tree.to_str().unwrap(), &HashMap::new(), ctx, &None, &None).unwrap();
|
apply_quilt_patches(
|
||||||
|
tree.to_str().unwrap(),
|
||||||
|
&HashMap::new(),
|
||||||
|
ctx,
|
||||||
|
&crate::report::Quiet,
|
||||||
|
&None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+185
-102
@@ -5,7 +5,11 @@ pub(crate) mod ephemeral;
|
|||||||
mod local;
|
mod local;
|
||||||
|
|
||||||
use crate::context::{self, Context};
|
use crate::context::{self, Context};
|
||||||
use crate::ui::deb::{DebUi, Phase};
|
use crate::logfmt::{
|
||||||
|
AptInstallClassifier, AptUpdateClassifier, Classifier, GenericClassifier, MakeClassifier,
|
||||||
|
MmdebstrapClassifier, QuiltClassifier,
|
||||||
|
};
|
||||||
|
use crate::report::{BuildTarget, BuildView};
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -17,67 +21,163 @@ pub enum BuildMode {
|
|||||||
Local,
|
Local,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phases of a binary build, announced to the [`BuildView`]
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Phase {
|
||||||
|
/// Downloading the chroot tarball (mmdebstrap)
|
||||||
|
PreparingChroot,
|
||||||
|
/// Extracting the chroot tarball
|
||||||
|
ExtractingChroot,
|
||||||
|
/// Device nodes, /proc bind mount, etc.
|
||||||
|
FinalizingChroot,
|
||||||
|
/// apt-get update
|
||||||
|
UpdatingPackageLists,
|
||||||
|
/// Installing build-essential & co
|
||||||
|
InstallingEssentials,
|
||||||
|
/// quilt push -a
|
||||||
|
ApplyingPatches,
|
||||||
|
/// --inject packages
|
||||||
|
InjectingPackages,
|
||||||
|
/// apt-get build-dep
|
||||||
|
InstallingBuildDeps,
|
||||||
|
/// debian/rules build
|
||||||
|
Building,
|
||||||
|
/// fakeroot debian/rules binary
|
||||||
|
ProducingBinaries,
|
||||||
|
/// Retrieving produced .deb files
|
||||||
|
RetrievingArtifacts,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Phase {
|
||||||
|
/// Human-readable label displayed in the status bar
|
||||||
|
pub fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Phase::PreparingChroot => "Preparing chroot",
|
||||||
|
Phase::ExtractingChroot => "Extracting chroot",
|
||||||
|
Phase::FinalizingChroot => "Finalizing chroot",
|
||||||
|
Phase::UpdatingPackageLists => "Updating package lists",
|
||||||
|
Phase::InstallingEssentials => "Installing essential packages",
|
||||||
|
Phase::ApplyingPatches => "Applying patches",
|
||||||
|
Phase::InjectingPackages => "Injecting packages",
|
||||||
|
Phase::InstallingBuildDeps => "Installing build dependencies",
|
||||||
|
Phase::Building => "Building package",
|
||||||
|
Phase::ProducingBinaries => "Producing binary packages",
|
||||||
|
Phase::RetrievingArtifacts => "Retrieving artifacts",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default line classifier rewriting a phase's subprocess output
|
||||||
|
fn default_classifier(phase: Phase) -> Box<dyn Classifier> {
|
||||||
|
match phase {
|
||||||
|
Phase::PreparingChroot => Box::new(MmdebstrapClassifier::new()),
|
||||||
|
Phase::ExtractingChroot | Phase::FinalizingChroot => Box::new(GenericClassifier::new()),
|
||||||
|
Phase::UpdatingPackageLists => Box::new(AptUpdateClassifier::new()),
|
||||||
|
Phase::InstallingEssentials => Box::new(AptInstallClassifier::new("Installing essentials")),
|
||||||
|
Phase::ApplyingPatches => Box::new(QuiltClassifier::new(0)),
|
||||||
|
Phase::InjectingPackages => Box::new(AptInstallClassifier::new("Injecting packages")),
|
||||||
|
Phase::InstallingBuildDeps => {
|
||||||
|
Box::new(AptInstallClassifier::new("Installing build dependencies"))
|
||||||
|
}
|
||||||
|
Phase::Building | Phase::ProducingBinaries => Box::new(MakeClassifier::new()),
|
||||||
|
Phase::RetrievingArtifacts => Box::new(GenericClassifier::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter `phase` on the view with its default line classifier
|
||||||
|
pub(crate) fn enter_phase(view: &dyn BuildView, phase: Phase) {
|
||||||
|
view.phase(phase.label(), default_classifier(phase));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters of one [`build_binary_package`] call.
|
||||||
|
pub struct DebBuildOptions<'a> {
|
||||||
|
/// Target architecture; defaults to the host architecture.
|
||||||
|
pub arch: Option<String>,
|
||||||
|
/// Target distribution series; defaults to the changelog series
|
||||||
|
/// (UNRELEASED resolves to the vendor's development series).
|
||||||
|
pub series: Option<String>,
|
||||||
|
/// Distribution pocket to resolve build-dependencies from.
|
||||||
|
pub pocket: Option<String>,
|
||||||
|
/// Source tree to build; defaults to the process working directory.
|
||||||
|
pub cwd: Option<PathBuf>,
|
||||||
|
/// Cross-compile for the target architecture instead of using
|
||||||
|
/// qemu-binfmt.
|
||||||
|
pub cross: bool,
|
||||||
|
/// Build mode; defaults to [`BuildMode::Local`].
|
||||||
|
pub mode: Option<BuildMode>,
|
||||||
|
/// PPAs to add for build-dependencies (`user/ppa_name`).
|
||||||
|
pub ppa: Vec<String>,
|
||||||
|
/// Packages to inject into the build environment before build-dep
|
||||||
|
/// (.deb paths, archive names or PPA packages).
|
||||||
|
pub inject: Vec<String>,
|
||||||
|
/// Parallel build jobs; defaults to the core count available in the
|
||||||
|
/// build context.
|
||||||
|
pub jobs: Option<usize>,
|
||||||
|
/// Explicit build context; defaults to the current context.
|
||||||
|
pub ctx: Option<Arc<Context>>,
|
||||||
|
/// Where build events (phases, progress, outcome) are reported.
|
||||||
|
pub view: &'a dyn BuildView,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DebBuildOptions<'_> {
|
||||||
|
fn default() -> Self {
|
||||||
|
static QUIET: crate::report::Quiet = crate::report::Quiet;
|
||||||
|
DebBuildOptions {
|
||||||
|
arch: None,
|
||||||
|
series: None,
|
||||||
|
pocket: None,
|
||||||
|
cwd: None,
|
||||||
|
cross: false,
|
||||||
|
mode: None,
|
||||||
|
ppa: Vec::new(),
|
||||||
|
inject: Vec::new(),
|
||||||
|
jobs: None,
|
||||||
|
ctx: None,
|
||||||
|
view: &QUIET,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Build package in 'cwd' to a .deb
|
/// Build package in 'cwd' to a .deb
|
||||||
///
|
///
|
||||||
/// Returns the list of produced artifacts (.deb files plus the upload
|
/// Returns the list of produced artifacts (.deb files plus the upload
|
||||||
/// metadata `.buildinfo`/`.changes`) retrieved locally, identified from
|
/// metadata `.buildinfo`/`.changes`) retrieved locally, identified from
|
||||||
/// `debian/files` and the native metadata generation rather than by
|
/// `debian/files` and the native metadata generation rather than by
|
||||||
/// globbing the build root (which would surface stale files). When `ui` is
|
/// globbing the build root (which would surface stale files). Subprocess
|
||||||
/// set, a live view (status bar + rolling log pane) is displayed and all
|
/// output is captured through the view's sink (live view + tee log for the
|
||||||
/// subprocess output is captured through it; on failure the widget is
|
/// terminal adapter); on failure the view is cleared and prints a summary
|
||||||
/// cleared and a summary of captured errors is printed.
|
/// of captured errors.
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub async fn build_binary_package(
|
pub async fn build_binary_package(
|
||||||
arch: Option<&str>,
|
opts: DebBuildOptions<'_>,
|
||||||
series: Option<&str>,
|
|
||||||
pocket: Option<&str>,
|
|
||||||
cwd: Option<&Path>,
|
|
||||||
cross: bool,
|
|
||||||
mode: Option<BuildMode>,
|
|
||||||
ppa: Option<&[&str]>,
|
|
||||||
inject_packages: Option<&[&str]>,
|
|
||||||
ctx: Option<Arc<Context>>,
|
|
||||||
ui: Option<Arc<DebUi>>,
|
|
||||||
jobs: Option<usize>,
|
|
||||||
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
|
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
|
||||||
let result = build_binary_package_impl(
|
let view = opts.view;
|
||||||
arch,
|
let result = build_binary_package_impl(opts).await;
|
||||||
series,
|
|
||||||
pocket,
|
|
||||||
cwd,
|
|
||||||
cross,
|
|
||||||
mode,
|
|
||||||
ppa,
|
|
||||||
inject_packages,
|
|
||||||
ctx,
|
|
||||||
&ui,
|
|
||||||
jobs,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let (Some(u), Err(_)) = (&ui, &result) {
|
if result.is_err() {
|
||||||
u.finish_failure();
|
view.finish_failure();
|
||||||
}
|
}
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Implementation of [`build_binary_package`], without failure handling
|
/// Implementation of [`build_binary_package`], without failure handling
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
async fn build_binary_package_impl(
|
async fn build_binary_package_impl(
|
||||||
arch: Option<&str>,
|
opts: DebBuildOptions<'_>,
|
||||||
series: Option<&str>,
|
|
||||||
pocket: Option<&str>,
|
|
||||||
cwd: Option<&Path>,
|
|
||||||
cross: bool,
|
|
||||||
mode: Option<BuildMode>,
|
|
||||||
ppa: Option<&[&str]>,
|
|
||||||
inject_packages: Option<&[&str]>,
|
|
||||||
ctx: Option<Arc<Context>>,
|
|
||||||
ui: &Option<Arc<DebUi>>,
|
|
||||||
jobs: Option<usize>,
|
|
||||||
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
|
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
|
||||||
let cwd = cwd.unwrap_or_else(|| Path::new("."));
|
let DebBuildOptions {
|
||||||
|
ref arch,
|
||||||
|
ref series,
|
||||||
|
ref pocket,
|
||||||
|
ref cwd,
|
||||||
|
cross,
|
||||||
|
ref mode,
|
||||||
|
ref ppa,
|
||||||
|
ref inject,
|
||||||
|
ref jobs,
|
||||||
|
ref ctx,
|
||||||
|
view,
|
||||||
|
} = opts;
|
||||||
|
let cwd = cwd.as_deref().unwrap_or_else(|| Path::new("."));
|
||||||
|
|
||||||
// Parse changelog to get package name, version and series
|
// Parse changelog to get package name, version and series
|
||||||
let changelog_path = cwd.join("debian/changelog");
|
let changelog_path = cwd.join("debian/changelog");
|
||||||
@@ -101,44 +201,45 @@ async fn build_binary_package_impl(
|
|||||||
&package_series
|
&package_series
|
||||||
};
|
};
|
||||||
let current_arch = crate::get_current_arch();
|
let current_arch = crate::get_current_arch();
|
||||||
let arch = arch.unwrap_or(¤t_arch);
|
let arch = arch.as_deref().unwrap_or(¤t_arch);
|
||||||
|
|
||||||
// Make sure we select a specific mode, either using user-requested
|
// Make sure we select a specific mode, either using user-requested
|
||||||
// or by using default for user-supplied parameters
|
// or by using default for user-supplied parameters
|
||||||
let mode = if let Some(m) = mode {
|
let default_mode = BuildMode::Local;
|
||||||
m
|
let mode = mode.as_ref().unwrap_or(&default_mode);
|
||||||
} else {
|
|
||||||
// By default, we use local build
|
|
||||||
BuildMode::Local
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create an ephemeral unshare context for all Local builds
|
// Create an ephemeral unshare context for all Local builds
|
||||||
// Use qemu_binfmt when target architecture differs from host and cross is not requested
|
// Use qemu_binfmt when target architecture differs from host and cross is not requested
|
||||||
let chroot_arch = if mode == BuildMode::Local && arch != current_arch && !cross {
|
let chroot_arch = if mode == &BuildMode::Local && arch != current_arch && !cross {
|
||||||
Some(arch)
|
Some(arch)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use provided context or get current
|
// Use provided context or get current
|
||||||
let base_ctx = ctx.unwrap_or_else(context::current);
|
let base_ctx = ctx.clone().unwrap_or_else(context::current);
|
||||||
|
|
||||||
// Identify the target in the live UI once the changelog is parsed, so
|
// Identify the target in the live view once the changelog is parsed, so
|
||||||
// even the chroot download output is attributed and tee'd
|
// even the chroot download output is attributed and tee'd
|
||||||
if let Some(u) = ui {
|
view.target(BuildTarget {
|
||||||
u.set_target(&package, &version, series, arch);
|
package: &package,
|
||||||
}
|
version: &version,
|
||||||
|
target: &format!("{series}/{arch}"),
|
||||||
|
display: format!("Building {package} ({version}) for {series}/{arch}"),
|
||||||
|
source_only: false,
|
||||||
|
tee_log: true,
|
||||||
|
});
|
||||||
|
|
||||||
// Create an ephemeral unshare context for all Local builds. It is kept in
|
// Create an ephemeral unshare context for all Local builds. It is kept in
|
||||||
// this scope so it outlives the guarded section below and is only dropped
|
// this scope so it outlives the guarded section below and is only dropped
|
||||||
// once the live view has been cleared.
|
// once the live view has been cleared.
|
||||||
let mut guard = if mode == BuildMode::Local {
|
let mut guard = if *mode == BuildMode::Local {
|
||||||
Some(
|
Some(
|
||||||
ephemeral::EphemeralContextGuard::new_with_context(
|
ephemeral::EphemeralContextGuard::new_with_context(
|
||||||
series,
|
series,
|
||||||
chroot_arch,
|
chroot_arch,
|
||||||
base_ctx.clone(),
|
base_ctx.clone(),
|
||||||
ui.clone(),
|
view,
|
||||||
)
|
)
|
||||||
.await?,
|
.await?,
|
||||||
)
|
)
|
||||||
@@ -179,14 +280,14 @@ async fn build_binary_package_impl(
|
|||||||
&version,
|
&version,
|
||||||
arch,
|
arch,
|
||||||
series,
|
series,
|
||||||
pocket,
|
pocket.as_deref(),
|
||||||
&build_root,
|
&build_root,
|
||||||
cross,
|
cross,
|
||||||
ppa,
|
ppa,
|
||||||
inject_packages,
|
inject,
|
||||||
build_ctx.clone(),
|
build_ctx.clone(),
|
||||||
ui.clone(),
|
view,
|
||||||
jobs,
|
*jobs,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
@@ -194,9 +295,7 @@ async fn build_binary_package_impl(
|
|||||||
|
|
||||||
// Retrieve the produced artifacts (binary packages plus the upload
|
// Retrieve the produced artifacts (binary packages plus the upload
|
||||||
// metadata) to the parent directory.
|
// metadata) to the parent directory.
|
||||||
if let Some(u) = ui {
|
enter_phase(view, Phase::RetrievingArtifacts);
|
||||||
u.phase(Phase::RetrievingArtifacts);
|
|
||||||
}
|
|
||||||
let total_debs = remote_files.len();
|
let total_debs = remote_files.len();
|
||||||
|
|
||||||
let mut artifacts = Vec::with_capacity(total_debs);
|
let mut artifacts = Vec::with_capacity(total_debs);
|
||||||
@@ -206,14 +305,10 @@ async fn build_binary_package_impl(
|
|||||||
build_ctx.retrieve_path(remote_file, &local_dest)?;
|
build_ctx.retrieve_path(remote_file, &local_dest)?;
|
||||||
artifacts.push(local_dest);
|
artifacts.push(local_dest);
|
||||||
|
|
||||||
if let Some(u) = ui {
|
view.progress("Retrieving artifacts", idx + 1, total_debs);
|
||||||
u.count_progress("Retrieving artifacts", idx + 1, total_debs);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(u) = ui {
|
view.finish_success(&artifacts);
|
||||||
u.finish_success(&artifacts, u.elapsed());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(artifacts)
|
Ok(artifacts)
|
||||||
}
|
}
|
||||||
@@ -222,9 +317,7 @@ async fn build_binary_package_impl(
|
|||||||
// Clear the live view before returning: the ephemeral guard is dropped at
|
// Clear the live view before returning: the ephemeral guard is dropped at
|
||||||
// the end of this function and its cleanup commands (umount, rm -rf of
|
// the end of this function and its cleanup commands (umount, rm -rf of
|
||||||
// the chroot) inherit the terminal, so they must not fight the widget.
|
// the chroot) inherit the terminal, so they must not fight the widget.
|
||||||
if let Some(u) = ui {
|
view.suspend();
|
||||||
u.suspend();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark build as successful to trigger chroot cleanup
|
// Mark build as successful to trigger chroot cleanup
|
||||||
if result.is_ok()
|
if result.is_ok()
|
||||||
@@ -446,19 +539,14 @@ mod tests {
|
|||||||
log::debug!("Package directory: {}", cwd.display());
|
log::debug!("Package directory: {}", cwd.display());
|
||||||
|
|
||||||
log::info!("Starting binary package build...");
|
log::info!("Starting binary package build...");
|
||||||
crate::deb::build_binary_package(
|
crate::deb::build_binary_package(DebBuildOptions {
|
||||||
arch,
|
arch: arch.map(str::to_string),
|
||||||
Some(series),
|
series: Some(series.to_string()),
|
||||||
None,
|
|
||||||
Some(&cwd),
|
|
||||||
cross,
|
cross,
|
||||||
None,
|
cwd: Some(cwd.to_path_buf()),
|
||||||
None,
|
ctx: Some(ctx),
|
||||||
None,
|
..Default::default()
|
||||||
Some(ctx),
|
})
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.expect("Cannot build binary package (deb)");
|
.expect("Cannot build binary package (deb)");
|
||||||
log::info!("Successfully built binary package");
|
log::info!("Successfully built binary package");
|
||||||
@@ -620,19 +708,14 @@ mod tests {
|
|||||||
|
|
||||||
let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap());
|
let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap());
|
||||||
|
|
||||||
crate::deb::build_binary_package(
|
crate::deb::build_binary_package(DebBuildOptions {
|
||||||
Some("arm64"),
|
arch: Some("arm64".to_string()),
|
||||||
Some("noble"),
|
series: Some("noble".to_string()),
|
||||||
None,
|
cwd: Some(pkg_dir),
|
||||||
Some(&pkg_dir),
|
cross: true,
|
||||||
true,
|
ctx: Some(ctx),
|
||||||
None,
|
..Default::default()
|
||||||
None,
|
})
|
||||||
None,
|
|
||||||
Some(ctx),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.expect("Cannot cross-build package declaring Build-Depends-Indep");
|
.expect("Cannot cross-build package declaring Build-Depends-Indep");
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ pub mod put;
|
|||||||
/// Handle package-specific quirks and workarounds
|
/// Handle package-specific quirks and workarounds
|
||||||
pub mod quirks;
|
pub mod quirks;
|
||||||
|
|
||||||
|
/// Line classifiers rewriting raw subprocess output into display actions
|
||||||
|
/// and countable progress (pure logic, shared by build views)
|
||||||
|
pub mod logfmt;
|
||||||
|
|
||||||
|
/// Reporting ports: environment-agnostic build observation ([`BuildView`])
|
||||||
|
/// and question answering ([`Prompter`]), implemented by terminal views,
|
||||||
|
/// server bridges or the inert [`Quiet`]
|
||||||
|
pub mod report;
|
||||||
|
|
||||||
/// Terminal UI helpers (progress bars, live build views, prompts)
|
/// Terminal UI helpers (progress bars, live build views, prompts)
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
|
||||||
|
|||||||
+130
-135
@@ -321,10 +321,14 @@ fn main() {
|
|||||||
// the structural self-checks inside `scaffold` always run), with
|
// the structural self-checks inside `scaffold` always run), with
|
||||||
// the scaffold outcome (e.g. a failed vendoring) shaping the
|
// the scaffold outcome (e.g. a failed vendoring) shaping the
|
||||||
// offer.
|
// offer.
|
||||||
|
let prompter = pkh::ui::prompt::TerminalPrompter;
|
||||||
if let Err(e) = rt.block_on(async {
|
if let Err(e) = rt.block_on(async {
|
||||||
let opts = pkh::new::questions::run(cli).await?;
|
let opts = pkh::new::questions::run(cli, &prompter).await?;
|
||||||
let outcome = pkh::new::scaffold(opts.clone(), &multi)?;
|
let outcome = pkh::new::scaffold(opts.clone(), &multi)?;
|
||||||
pkh::new::questions::offer_verification(&opts, &outcome, &multi, no_verify).await;
|
pkh::new::questions::offer_verification(
|
||||||
|
&opts, &outcome, &multi, no_verify, &prompter,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
Ok::<(), Box<dyn std::error::Error>>(())
|
Ok::<(), Box<dyn std::error::Error>>(())
|
||||||
}) {
|
}) {
|
||||||
error!("{}", e);
|
error!("{}", e);
|
||||||
@@ -349,15 +353,14 @@ fn main() {
|
|||||||
let (pb, progress_callback) = pkh::ui::create_progress_bar(&multi);
|
let (pb, progress_callback) = pkh::ui::create_progress_bar(&multi);
|
||||||
|
|
||||||
// Convert PPA to base URL if provided
|
// Convert PPA to base URL if provided
|
||||||
let base_url = ppa.map(|ppa_str| {
|
let base_url = match ppa.map(pkh::package_info::split_ppa) {
|
||||||
// PPA format: user/ppa_name
|
Some(Ok((user, name))) => Some(pkh::package_info::ppa_to_base_url(user, name)),
|
||||||
let parts: Vec<&str> = ppa_str.split('/').collect();
|
Some(Err(e)) => {
|
||||||
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
|
error!("{e}");
|
||||||
error!("Invalid PPA format: '{}'. Expected: user/ppa_name", ppa_str);
|
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
pkh::package_info::ppa_to_base_url(parts[0], parts[1])
|
None => None,
|
||||||
});
|
};
|
||||||
|
|
||||||
// Since pull is async, we need to block on it
|
// Since pull is async, we need to block on it
|
||||||
if let Err(e) = rt.block_on(async {
|
if let Err(e) = rt.block_on(async {
|
||||||
@@ -391,74 +394,47 @@ fn main() {
|
|||||||
let target_series = if let Some(s) = cli_series {
|
let target_series = if let Some(s) = cli_series {
|
||||||
Some(s.to_string())
|
Some(s.to_string())
|
||||||
} else {
|
} else {
|
||||||
// Parse current changelog to determine the default series
|
|
||||||
let changelog_path = cwd.join("debian/changelog");
|
let changelog_path = cwd.join("debian/changelog");
|
||||||
match pkh::changelog::parse_changelog_header(&changelog_path) {
|
match rt.block_on(pkh::changelog::series_candidates(&changelog_path)) {
|
||||||
Ok((_pkg, _ver, current_series)) => {
|
Some(pkh::changelog::SeriesCandidates::Choose {
|
||||||
// UNRELEASED is not a real series: offer it as a
|
options,
|
||||||
// pinned first entry (selecting it keeps the changelog
|
default,
|
||||||
// unreleased) on top of the current vendor's series
|
fallback,
|
||||||
// list, defaulting to the development series. Any
|
}) => match pkh::ui::select_series(&options, &default) {
|
||||||
// other series resolves through the series list of
|
Ok(selected) => Some(selected),
|
||||||
// its own distribution.
|
Err(e) => {
|
||||||
match rt.block_on(async {
|
error!(
|
||||||
if pkh::distro_info::is_unreleased(¤t_series) {
|
"Series selection failed: {}. Using current series '{}' instead.",
|
||||||
// Vendors keep original casing ("Ubuntu"),
|
e, fallback
|
||||||
// while the series data keys are lowercase
|
);
|
||||||
let dist = pkh::build::env::current_vendor().to_lowercase();
|
Some(fallback)
|
||||||
let mut series_list =
|
|
||||||
vec![pkh::distro_info::UNRELEASED.to_string()];
|
|
||||||
series_list.extend(
|
|
||||||
pkh::distro_info::get_ordered_series_name(&dist).await?,
|
|
||||||
);
|
|
||||||
Ok(series_list)
|
|
||||||
} else {
|
|
||||||
let dist =
|
|
||||||
pkh::distro_info::get_dist_from_series(¤t_series).await?;
|
|
||||||
pkh::distro_info::get_ordered_series_name(&dist).await
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Ok(series_list) => {
|
|
||||||
// Default to the development series (the
|
|
||||||
// first real entry) when the changelog is
|
|
||||||
// UNRELEASED, not to the pinned entry itself
|
|
||||||
let default = if pkh::distro_info::is_unreleased(¤t_series)
|
|
||||||
&& series_list.len() > 1
|
|
||||||
{
|
|
||||||
series_list[1].clone()
|
|
||||||
} else {
|
|
||||||
current_series.clone()
|
|
||||||
};
|
|
||||||
match pkh::ui::select_series(&series_list, &default) {
|
|
||||||
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,
|
// 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,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = generate_entry(
|
let entry = match generate_entry(
|
||||||
"debian/changelog",
|
"debian/changelog",
|
||||||
Some(&cwd),
|
Some(&cwd),
|
||||||
version,
|
version,
|
||||||
target_series.as_deref(),
|
target_series.as_deref(),
|
||||||
) {
|
) {
|
||||||
error!("{}", e);
|
Ok(entry) => entry,
|
||||||
std::process::exit(1);
|
Err(e) => {
|
||||||
}
|
error!("{}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"Found package: {}, version: {}",
|
||||||
|
entry.package, entry.previous_version
|
||||||
|
);
|
||||||
|
println!("Added new changelog entry to {}", entry.path.display());
|
||||||
|
|
||||||
let editor = match std::env::var("EDITOR") {
|
let editor = match std::env::var("EDITOR") {
|
||||||
Ok(e) => e,
|
Ok(e) => e,
|
||||||
@@ -490,13 +466,19 @@ fn main() {
|
|||||||
.copied()
|
.copied()
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
// Live build view: disabled by --verbose or when stdout is not a
|
// Live build view, unless --verbose (DebUi additionally disables
|
||||||
// terminal (DebUi handles the non-TTY case itself)
|
// itself when stdout is not a terminal)
|
||||||
let ui = if verbose {
|
let quiet = pkh::report::Quiet;
|
||||||
|
let live = if verbose {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(std::sync::Arc::new(pkh::ui::deb::DebUi::new(&multi)))
|
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) {
|
let orig_source = match sub_matches.get_one::<String>("orig").map(String::as_str) {
|
||||||
Some("always") => pkh::build::OrigSourceMode::Always,
|
Some("always") => pkh::build::OrigSourceMode::Always,
|
||||||
@@ -504,23 +486,48 @@ fn main() {
|
|||||||
_ => pkh::build::OrigSourceMode::Auto,
|
_ => pkh::build::OrigSourceMode::Auto,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = pkh::build::build_source_package(
|
match pkh::build::build_source_package(pkh::build::BuildSourceOptions {
|
||||||
Some(&cwd),
|
source: Some(cwd),
|
||||||
pkh::build::SourceBuildOptions {
|
options: pkh::build::SourceBuildOptions {
|
||||||
orig_source,
|
orig_source,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
ui,
|
view,
|
||||||
) {
|
prompter: &prompter,
|
||||||
error!("{}", e);
|
}) {
|
||||||
// Unmet build dependencies/conflicts exit with status 3,
|
Ok(output) => {
|
||||||
// like dpkg-buildpackage does.
|
// The live view lists the artifacts itself when it
|
||||||
if e.downcast_ref::<pkh::debian::deps::UnmetBuildDependencies>()
|
// renders; otherwise (verbose mode or non-TTY stdout)
|
||||||
.is_some()
|
// print them as plain lines.
|
||||||
{
|
if !view.is_enabled() {
|
||||||
std::process::exit(3);
|
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) => {
|
||||||
|
// 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);
|
||||||
}
|
}
|
||||||
std::process::exit(1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(("put", sub_matches)) => {
|
Some(("put", sub_matches)) => {
|
||||||
@@ -543,41 +550,38 @@ fn main() {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let view = pkh::ui::deb::DebUi::new(&multi);
|
||||||
|
let prompter = pkh::ui::prompt::TerminalPrompter;
|
||||||
let options = pkh::put::PutOptions {
|
let options = pkh::put::PutOptions {
|
||||||
ppa: ppa.to_string(),
|
ppa: ppa.to_string(),
|
||||||
changes,
|
changes,
|
||||||
force,
|
force,
|
||||||
cwd,
|
cwd,
|
||||||
|
view: &view,
|
||||||
|
prompter: &prompter,
|
||||||
};
|
};
|
||||||
if let Err(e) = rt.block_on(async { pkh::put::put(&options, &multi).await }) {
|
if let Err(e) = rt.block_on(async { pkh::put::put(&options).await }) {
|
||||||
error!("{}", e);
|
error!("{}", e);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(("deb", sub_matches)) => {
|
Some(("deb", sub_matches)) => {
|
||||||
let cwd = current_dir_or_exit();
|
let cwd = current_dir_or_exit();
|
||||||
let series = sub_matches.get_one::<String>("series").map(|s| s.as_str());
|
let series = sub_matches.get_one::<String>("series").cloned();
|
||||||
let pocket = sub_matches.get_one::<String>("pocket").map(|s| s.as_str());
|
let pocket = sub_matches.get_one::<String>("pocket").cloned();
|
||||||
let arch = sub_matches.get_one::<String>("arch").map(|s| s.as_str());
|
let arch = sub_matches.get_one::<String>("arch").cloned();
|
||||||
let cross = sub_matches.get_one::<bool>("cross").unwrap_or(&false);
|
let cross = sub_matches
|
||||||
let ppa: Vec<&str> = sub_matches
|
.get_one::<bool>("cross")
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(false);
|
||||||
|
let ppa: Vec<String> = sub_matches
|
||||||
.get_many::<String>("ppa")
|
.get_many::<String>("ppa")
|
||||||
.map(|v| v.map(|s| s.as_str()).collect())
|
.map(|v| v.cloned().collect())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let ppa = if ppa.is_empty() {
|
let inject: Vec<String> = sub_matches
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(ppa.as_slice())
|
|
||||||
};
|
|
||||||
let inject_packages: Vec<&str> = sub_matches
|
|
||||||
.get_many::<String>("inject")
|
.get_many::<String>("inject")
|
||||||
.map(|v| v.map(|s| s.as_str()).collect())
|
.map(|v| v.cloned().collect())
|
||||||
.unwrap_or_default();
|
.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::<String>("mode").map(|s| s.as_str());
|
let mode: Option<&str> = sub_matches.get_one::<String>("mode").map(|s| s.as_str());
|
||||||
let mode: Option<pkh::deb::BuildMode> = match mode {
|
let mode: Option<pkh::deb::BuildMode> = match mode {
|
||||||
Some("local") => Some(pkh::deb::BuildMode::Local),
|
Some("local") => Some(pkh::deb::BuildMode::Local),
|
||||||
@@ -596,36 +600,38 @@ fn main() {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
// Live build view: disabled by --verbose or when stdout is not a
|
// Live build view, unless --verbose (DebUi additionally disables
|
||||||
// terminal (DebUi handles the non-TTY case itself)
|
// itself when stdout is not a terminal)
|
||||||
let ui = if verbose {
|
let quiet = pkh::report::Quiet;
|
||||||
|
let live = if verbose {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(std::sync::Arc::new(pkh::ui::deb::DebUi::new(&multi)))
|
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 {
|
let result = rt.block_on(async {
|
||||||
pkh::deb::build_binary_package(
|
pkh::deb::build_binary_package(pkh::deb::DebBuildOptions {
|
||||||
arch,
|
arch,
|
||||||
series,
|
series,
|
||||||
pocket,
|
pocket,
|
||||||
Some(cwd.as_path()),
|
cwd: Some(cwd.clone()),
|
||||||
*cross,
|
cross,
|
||||||
mode,
|
mode,
|
||||||
ppa,
|
ppa,
|
||||||
inject_packages,
|
inject,
|
||||||
None,
|
|
||||||
ui.clone(),
|
|
||||||
jobs,
|
jobs,
|
||||||
)
|
view,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
});
|
});
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(artifacts) => {
|
Ok(_) => info!("Done."),
|
||||||
let _ = artifacts;
|
|
||||||
info!("Done.");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("{}", e);
|
error!("{}", e);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
@@ -655,24 +661,13 @@ fn main() {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse host, user, port from endpoint
|
match pkh::context::ContextConfig::from_endpoint(endpoint) {
|
||||||
// Formats: [ssh://][user@]host[:port]
|
Ok(config) => config,
|
||||||
let endpoint_re = regex::Regex::new(r"^(?:ssh://)?(?:(?P<user>[^@]+)@)?(?P<host>[^:/]+)(?::(?P<port>\d+))?$").unwrap();
|
Err(e) => {
|
||||||
let endpoint_cap = endpoint_re.captures(endpoint).unwrap_or_else(|| {
|
error!("{e}");
|
||||||
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::<u16>().unwrap_or_else(|_| {
|
|
||||||
error!("Invalid port number");
|
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
})
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
ContextConfig::Ssh { host, user, port }
|
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
error!("Unknown context type: {}", type_str);
|
error!("Unknown context type: {}", type_str);
|
||||||
|
|||||||
+1
-1
@@ -436,7 +436,7 @@ pub fn create_orig_tarball_excluding(
|
|||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"Created orig tarball {}",
|
"Created orig tarball {}",
|
||||||
crate::ui::display_path(&tarball_path)
|
crate::report::display_path(&tarball_path)
|
||||||
);
|
);
|
||||||
Ok(tarball_path)
|
Ok(tarball_path)
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -251,7 +251,7 @@ fn print_success(opts: &NewOptions, outcome: &ScaffoldOutcome) {
|
|||||||
// `display_path` yields an empty string when the target is the cwd
|
// `display_path` yields an empty string when the target is the cwd
|
||||||
// itself (Here mode): `Created .` would be cryptic, so spell the
|
// itself (Here mode): `Created .` would be cryptic, so spell the
|
||||||
// location out; the skeleton/path modes keep the `<dir>` display.
|
// location out; the skeleton/path modes keep the `<dir>` display.
|
||||||
let display = crate::ui::display_path(&target);
|
let display = crate::report::display_path(&target);
|
||||||
let location = if display.is_empty() {
|
let location = if display.is_empty() {
|
||||||
"package in the current directory".to_string()
|
"package in the current directory".to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -819,7 +819,7 @@ mod tests {
|
|||||||
let output = crate::build::run_source_build(
|
let output = crate::build::run_source_build(
|
||||||
&source,
|
&source,
|
||||||
&crate::build::SourceBuildOptions::default(),
|
&crate::build::SourceBuildOptions::default(),
|
||||||
None,
|
&crate::report::Quiet,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let dsc = std::fs::read_to_string(&output.dsc).unwrap();
|
let dsc = std::fs::read_to_string(&output.dsc).unwrap();
|
||||||
@@ -885,7 +885,7 @@ mod tests {
|
|||||||
let output = crate::build::run_source_build(
|
let output = crate::build::run_source_build(
|
||||||
&dir.path().join("mytool"),
|
&dir.path().join("mytool"),
|
||||||
&crate::build::SourceBuildOptions::default(),
|
&crate::build::SourceBuildOptions::default(),
|
||||||
None,
|
&crate::report::Quiet,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -916,7 +916,7 @@ mod tests {
|
|||||||
let output = crate::build::run_source_build(
|
let output = crate::build::run_source_build(
|
||||||
&dir.path().join("mytool"),
|
&dir.path().join("mytool"),
|
||||||
&crate::build::SourceBuildOptions::default(),
|
&crate::build::SourceBuildOptions::default(),
|
||||||
None,
|
&crate::report::Quiet,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(output.dsc.exists(), "{:?} missing", output.dsc);
|
assert!(output.dsc.exists(), "{:?} missing", output.dsc);
|
||||||
|
|||||||
+4
-4
@@ -183,7 +183,7 @@ pub fn create_vendor_component(
|
|||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"Created vendored-dependencies component {}",
|
"Created vendored-dependencies component {}",
|
||||||
crate::ui::display_path(&component_path)
|
crate::report::display_path(&component_path)
|
||||||
);
|
);
|
||||||
Ok(component_path)
|
Ok(component_path)
|
||||||
}
|
}
|
||||||
@@ -283,7 +283,7 @@ fn git_archive_tarball(
|
|||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"Created orig tarball from git archive of {tag}: {}",
|
"Created orig tarball from git archive of {tag}: {}",
|
||||||
crate::ui::display_path(&dest)
|
crate::report::display_path(&dest)
|
||||||
);
|
);
|
||||||
Ok(dest)
|
Ok(dest)
|
||||||
}
|
}
|
||||||
@@ -311,7 +311,7 @@ fn download_release(
|
|||||||
Ok(path) => {
|
Ok(path) => {
|
||||||
log::info!(
|
log::info!(
|
||||||
"Created orig tarball from the release download of {tag}: {}",
|
"Created orig tarball from the release download of {tag}: {}",
|
||||||
crate::ui::display_path(&path)
|
crate::report::display_path(&path)
|
||||||
);
|
);
|
||||||
Ok(path)
|
Ok(path)
|
||||||
}
|
}
|
||||||
@@ -352,7 +352,7 @@ fn fetch_and_repack(
|
|||||||
log::info!(
|
log::info!(
|
||||||
"Created orig tarball from {}: {}",
|
"Created orig tarball from {}: {}",
|
||||||
source,
|
source,
|
||||||
crate::ui::display_path(&dest)
|
crate::report::display_path(&dest)
|
||||||
);
|
);
|
||||||
Ok(dest)
|
Ok(dest)
|
||||||
}
|
}
|
||||||
|
|||||||
+91
-83
@@ -1,11 +1,11 @@
|
|||||||
//! The `pkh new` interactive wizard.
|
//! The `pkh new` interactive wizard.
|
||||||
//!
|
//!
|
||||||
//! [`run`] is the single entry point: on an interactive terminal it asks the
|
//! [`run`] is the single entry point: when the prompter can interact it asks
|
||||||
//! questions of the spec's "Proposed UX" transcript, fills a
|
//! the questions of the spec's "Proposed UX" transcript, fills a
|
||||||
//! [`NewCli`] with the answers (explicit flags are never re-asked), and
|
//! [`NewCli`] with the answers (explicit flags are never re-asked), and
|
||||||
//! reuses [`options::resolve`] as the single source of truth for defaults,
|
//! reuses [`options::resolve`] as the single source of truth for defaults,
|
||||||
//! detection and validation — so the non-interactive and interactive paths
|
//! detection and validation — so the non-interactive and interactive paths
|
||||||
//! cannot drift apart. Without a terminal (or with `--defaults`) it goes
|
//! cannot drift apart. Headless (or with `--defaults`) it goes
|
||||||
//! straight through [`options::resolve`], whose error lists every missing
|
//! straight through [`options::resolve`], whose error lists every missing
|
||||||
//! answer.
|
//! answer.
|
||||||
//!
|
//!
|
||||||
@@ -13,11 +13,10 @@
|
|||||||
//! verification builds of the spec ([`offer_verification`]); a failed
|
//! verification builds of the spec ([`offer_verification`]); a failed
|
||||||
//! verification never undoes the scaffold.
|
//! verification never undoes the scaffold.
|
||||||
//!
|
//!
|
||||||
//! The prompt calls live in `run_wizard` and `offer_verification` only;
|
//! The prompter calls live in `run_wizard` and `offer_verification` only;
|
||||||
//! everything else in this module is pure and unit-tested.
|
//! everything else in this module is pure and unit-tested.
|
||||||
|
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::io::IsTerminal;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use indicatif::MultiProgress;
|
use indicatif::MultiProgress;
|
||||||
@@ -28,7 +27,7 @@ use crate::new::licenses;
|
|||||||
use crate::new::options::{self, NewCli, NewOptions, SourceDir, SourceFormat, TemplateId};
|
use crate::new::options::{self, NewCli, NewOptions, SourceDir, SourceFormat, TemplateId};
|
||||||
use crate::new::origin::GitOrigin;
|
use crate::new::origin::GitOrigin;
|
||||||
use crate::new::templates::{self, ProbeResult, ScaffoldOutcome};
|
use crate::new::templates::{self, ProbeResult, ScaffoldOutcome};
|
||||||
use crate::ui::prompt;
|
use crate::report::{Prompter, Validator};
|
||||||
|
|
||||||
/// Answer of the "where is the source code?" question: fresh skeleton.
|
/// Answer of the "where is the source code?" question: fresh skeleton.
|
||||||
const SOURCE_SKELETON: &str = "Create a new project skeleton here";
|
const SOURCE_SKELETON: &str = "Create a new project skeleton here";
|
||||||
@@ -40,8 +39,8 @@ const SOURCE_PATH: &str = "Package the sources in another directory…";
|
|||||||
/// The "everything else" entry of the license menu.
|
/// The "everything else" entry of the license menu.
|
||||||
const LICENSE_OTHER: &str = "Other (enter a SPDX identifier)";
|
const LICENSE_OTHER: &str = "Other (enter a SPDX identifier)";
|
||||||
|
|
||||||
/// Labels of the interactive `select` questions. `prompt::select` renders
|
/// Labels of the interactive `select` questions. The prompter renders
|
||||||
/// `> <label><answer>` verbatim — unlike [`prompt::text`], it appends no
|
/// `> <label><answer>` verbatim — unlike [`Prompter::text`], it appends no
|
||||||
/// formatting of its own — so each label carries its own separator:
|
/// formatting of its own — so each label carries its own separator:
|
||||||
/// field-style prompts end with `": "`, question-style ones with `"? "`.
|
/// field-style prompts end with `": "`, question-style ones with `"? "`.
|
||||||
const LANGUAGE_LABEL: &str = "Which language/build system is your program using? ";
|
const LANGUAGE_LABEL: &str = "Which language/build system is your program using? ";
|
||||||
@@ -62,20 +61,13 @@ const SELECT_LABELS: [&str; 6] = [
|
|||||||
ORIG_LABEL,
|
ORIG_LABEL,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Run the `pkh new` flow: the wizard on an interactive terminal, plain
|
/// Run the `pkh new` flow: the wizard when the prompter can interact,
|
||||||
/// [`options::resolve`] otherwise (and with `--defaults`).
|
/// plain [`options::resolve`] otherwise (and with `--defaults`).
|
||||||
pub async fn run(cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
pub async fn run(cli: NewCli, prompter: &dyn Prompter) -> Result<NewOptions, Box<dyn Error>> {
|
||||||
if cli.defaults || !is_interactive() {
|
if cli.defaults || !prompter.interactive() {
|
||||||
return Ok(options::resolve(cli).await?);
|
return Ok(options::resolve(cli).await?);
|
||||||
}
|
}
|
||||||
run_wizard(cli).await
|
run_wizard(cli, prompter).await
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether both ends of the terminal are interactive; the wizard and the
|
|
||||||
/// verification offers only run when this holds (the prompts' non-TTY
|
|
||||||
/// fallbacks would otherwise silently take defaults).
|
|
||||||
fn is_interactive() -> bool {
|
|
||||||
std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The wizard question flow (spec "Proposed UX"), in order:
|
/// The wizard question flow (spec "Proposed UX"), in order:
|
||||||
@@ -87,7 +79,10 @@ fn is_interactive() -> bool {
|
|||||||
/// (`empty` template only), git init — then the summary screen and the
|
/// (`empty` template only), git init — then the summary screen and the
|
||||||
/// final `Generate?` confirmation. Every question with an explicit flag
|
/// final `Generate?` confirmation. Every question with an explicit flag
|
||||||
/// answer is skipped (flag > detected/probe > default merge order).
|
/// answer is skipped (flag > detected/probe > default merge order).
|
||||||
async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
async fn run_wizard(
|
||||||
|
mut cli: NewCli,
|
||||||
|
prompter: &dyn Prompter,
|
||||||
|
) -> Result<NewOptions, Box<dyn Error>> {
|
||||||
let cwd = std::env::current_dir()?;
|
let cwd = std::env::current_dir()?;
|
||||||
let mut detect_dir = cli.source.clone().unwrap_or_else(|| cwd.clone());
|
let mut detect_dir = cli.source.clone().unwrap_or_else(|| cwd.clone());
|
||||||
let (detection, mut probe) = detect_and_probe(&detect_dir);
|
let (detection, mut probe) = detect_and_probe(&detect_dir);
|
||||||
@@ -101,7 +96,12 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
// basename of the current directory.
|
// basename of the current directory.
|
||||||
if cli.name.is_none() {
|
if cli.name.is_none() {
|
||||||
let default = default_package_name(&cwd, probe.as_ref());
|
let default = default_package_name(&cwd, probe.as_ref());
|
||||||
let answer = ask_text("Package name", &default, options::validate_source_name)?;
|
let answer = ask_text(
|
||||||
|
prompter,
|
||||||
|
"Package name",
|
||||||
|
&default,
|
||||||
|
options::validate_source_name,
|
||||||
|
)?;
|
||||||
cli.name = Some(answer);
|
cli.name = Some(answer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
.unwrap_or(TemplateId::EMPTY)
|
.unwrap_or(TemplateId::EMPTY)
|
||||||
.display_name()
|
.display_name()
|
||||||
.to_string();
|
.to_string();
|
||||||
let id = select_template(&menu, &default)?;
|
let id = select_template(prompter, &menu, &default)?;
|
||||||
cli.lang = Some(id.as_str().to_string());
|
cli.lang = Some(id.as_str().to_string());
|
||||||
}
|
}
|
||||||
LanguageChoice::Ambiguous(candidates) => {
|
LanguageChoice::Ambiguous(candidates) => {
|
||||||
@@ -157,7 +157,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
.join(", ")
|
.join(", ")
|
||||||
);
|
);
|
||||||
let menu = language_menu(&candidates);
|
let menu = language_menu(&candidates);
|
||||||
let id = select_template(&menu, &menu[0])?;
|
let id = select_template(prompter, &menu, &menu[0])?;
|
||||||
cli.lang = Some(id.as_str().to_string());
|
cli.lang = Some(id.as_str().to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,14 +193,14 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
} else {
|
} else {
|
||||||
SOURCE_HERE
|
SOURCE_HERE
|
||||||
};
|
};
|
||||||
let answer = select_from(SOURCE_LABEL, &options, default, |answer| {
|
let answer = select_from(prompter, SOURCE_LABEL, &options, default, |answer| {
|
||||||
options.contains(&answer.to_string())
|
options.contains(&answer.to_string())
|
||||||
})?;
|
})?;
|
||||||
if answer == SOURCE_HERE {
|
if answer == SOURCE_HERE {
|
||||||
cli.source = Some(cwd.clone());
|
cli.source = Some(cwd.clone());
|
||||||
} else if answer == SOURCE_PATH {
|
} else if answer == SOURCE_PATH {
|
||||||
let validator = |path: &str| validate_directory_answer(path);
|
let validator = |path: &str| validate_directory_answer(path);
|
||||||
let path = prompt::text("Source directory", "", Some(&validator))?;
|
let path = prompter.text("Source directory", "", Some(&validator))?;
|
||||||
cli.source = Some(PathBuf::from(path));
|
cli.source = Some(PathBuf::from(path));
|
||||||
}
|
}
|
||||||
// SOURCE_SKELETON: cli.source stays unset (the name decides).
|
// SOURCE_SKELETON: cli.source stays unset (the name decides).
|
||||||
@@ -266,7 +266,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
.unwrap_or_else(|| "0.1.0".to_string());
|
.unwrap_or_else(|| "0.1.0".to_string());
|
||||||
let revision = cli.revision.unwrap_or(1);
|
let revision = cli.revision.unwrap_or(1);
|
||||||
let validate = move |version: &str| options::validate_upstream_version(version, revision);
|
let validate = move |version: &str| options::validate_upstream_version(version, revision);
|
||||||
let answer = ask_text("Upstream version", &default, validate)?;
|
let answer = ask_text(prompter, "Upstream version", &default, validate)?;
|
||||||
cli.upstream_version = Some(answer.clone());
|
cli.upstream_version = Some(answer.clone());
|
||||||
|
|
||||||
// The typed version names an existing tag HEAD is not on: offer to
|
// The typed version names an existing tag HEAD is not on: offer to
|
||||||
@@ -279,7 +279,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
"Version {answer} matches tag {tag}, but HEAD is not that \
|
"Version {answer} matches tag {tag}, but HEAD is not that \
|
||||||
tag. Check out {tag} now?"
|
tag. Check out {tag} now?"
|
||||||
);
|
);
|
||||||
if prompt::confirm(&question, false)? {
|
if prompter.confirm(&question, false)? {
|
||||||
let dir = packaged_dir.as_deref().expect("origin implies a directory");
|
let dir = packaged_dir.as_deref().expect("origin implies a directory");
|
||||||
crate::new::origin::checkout_tag(dir, tag)?;
|
crate::new::origin::checkout_tag(dir, tag)?;
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -311,7 +311,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
} else {
|
} else {
|
||||||
"Snapshot this working tree".to_string()
|
"Snapshot this working tree".to_string()
|
||||||
};
|
};
|
||||||
let answer = select_from(ORIG_LABEL, &labels, &default, |answer| {
|
let answer = select_from(prompter, ORIG_LABEL, &labels, &default, |answer| {
|
||||||
labels.contains(&answer.to_string())
|
labels.contains(&answer.to_string())
|
||||||
})?;
|
})?;
|
||||||
let chosen = choices
|
let chosen = choices
|
||||||
@@ -322,14 +322,14 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
cli.orig_from = Some(chosen.to_string());
|
cli.orig_from = Some(chosen.to_string());
|
||||||
if chosen == "path" {
|
if chosen == "path" {
|
||||||
let validator = |path: &str| options::validate_orig_path(path);
|
let validator = |path: &str| options::validate_orig_path(path);
|
||||||
let path = prompt::text("Tarball path or URL", "", Some(&validator))?;
|
let path = prompter.text("Tarball path or URL", "", Some(&validator))?;
|
||||||
cli.orig_path = Some(path);
|
cli.orig_path = Some(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Debian revision.
|
// 5. Debian revision.
|
||||||
if cli.revision.is_none() {
|
if cli.revision.is_none() {
|
||||||
let answer = ask_text("Debian revision", "1", validate_revision_answer)?;
|
let answer = ask_text(prompter, "Debian revision", "1", validate_revision_answer)?;
|
||||||
cli.revision = answer.parse::<u32>().ok();
|
cli.revision = answer.parse::<u32>().ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,7 +340,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
.and_then(|p| p.description.clone())
|
.and_then(|p| p.description.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let validate = required_answer("the description");
|
let validate = required_answer("the description");
|
||||||
let answer = ask_text("One-line description", &default, validate)?;
|
let answer = ask_text(prompter, "One-line description", &default, validate)?;
|
||||||
cli.description = Some(answer);
|
cli.description = Some(answer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,7 +357,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
options::validate_homepage(url)
|
options::validate_homepage(url)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let answer = ask_text("Homepage (blank to skip)", &default, validate)?;
|
let answer = ask_text(prompter, "Homepage (blank to skip)", &default, validate)?;
|
||||||
if !answer.is_empty() {
|
if !answer.is_empty() {
|
||||||
cli.homepage = Some(answer);
|
cli.homepage = Some(answer);
|
||||||
}
|
}
|
||||||
@@ -373,12 +373,17 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
.or_else(|| detect::sniff_license(&detect_dir));
|
.or_else(|| detect::sniff_license(&detect_dir));
|
||||||
let (default, custom_default) = license_question_default(detected.as_deref());
|
let (default, custom_default) = license_question_default(detected.as_deref());
|
||||||
let options = license_menu();
|
let options = license_menu();
|
||||||
let answer = select_from(LICENSE_LABEL, &options, &default, |answer| {
|
let answer = select_from(prompter, LICENSE_LABEL, &options, &default, |answer| {
|
||||||
options.contains(&answer.to_string())
|
options.contains(&answer.to_string())
|
||||||
})?;
|
})?;
|
||||||
if answer == LICENSE_OTHER {
|
if answer == LICENSE_OTHER {
|
||||||
let validate = required_answer("the license identifier");
|
let validate = required_answer("the license identifier");
|
||||||
let license = ask_text("License (SPDX identifier)", &custom_default, validate)?;
|
let license = ask_text(
|
||||||
|
prompter,
|
||||||
|
"License (SPDX identifier)",
|
||||||
|
&custom_default,
|
||||||
|
validate,
|
||||||
|
)?;
|
||||||
cli.license = Some(license);
|
cli.license = Some(license);
|
||||||
} else {
|
} else {
|
||||||
cli.license = Some(answer);
|
cli.license = Some(answer);
|
||||||
@@ -395,7 +400,12 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|p| p.command.clone())
|
.and_then(|p| p.command.clone())
|
||||||
.unwrap_or_else(|| cli.name.clone().unwrap_or_default());
|
.unwrap_or_else(|| cli.name.clone().unwrap_or_default());
|
||||||
let command = ask_text("Command name", &default, options::validate_command)?;
|
let command = ask_text(
|
||||||
|
prompter,
|
||||||
|
"Command name",
|
||||||
|
&default,
|
||||||
|
options::validate_command,
|
||||||
|
)?;
|
||||||
cli.command = Some(command);
|
cli.command = Some(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,13 +431,13 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let validate = |answer: &str| options::parse_maintainer(answer).map(|_| ());
|
let validate = |answer: &str| options::parse_maintainer(answer).map(|_| ());
|
||||||
cli.maintainer = Some(ask_text("Maintainer", &default, validate)?);
|
cli.maintainer = Some(ask_text(prompter, "Maintainer", &default, validate)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 11. Target distribution. The menu derives from the distro data pkh
|
// 11. Target distribution. The menu derives from the distro data pkh
|
||||||
// ships (sorted); ubuntu is moved to the front when present so it
|
// ships (sorted); ubuntu is moved to the front when present so it
|
||||||
// stays the menu's first entry and fallback default as it has always
|
// stays the menu's first entry and fallback default as it has always
|
||||||
// been — prompt::select positions on a default value, not an index.
|
// been — the selector positions on a default value, not an index.
|
||||||
if cli.dist.is_none() {
|
if cli.dist.is_none() {
|
||||||
let vendor = crate::build::env::current_vendor().to_lowercase();
|
let vendor = crate::build::env::current_vendor().to_lowercase();
|
||||||
let mut options = crate::distro_info::supported_dists();
|
let mut options = crate::distro_info::supported_dists();
|
||||||
@@ -442,7 +452,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
} else {
|
} else {
|
||||||
"ubuntu".to_string()
|
"ubuntu".to_string()
|
||||||
};
|
};
|
||||||
let answer = select_from(DIST_LABEL, &options, &default, |answer| {
|
let answer = select_from(prompter, DIST_LABEL, &options, &default, |answer| {
|
||||||
options.contains(&answer.to_string())
|
options.contains(&answer.to_string())
|
||||||
})?;
|
})?;
|
||||||
cli.dist = Some(answer);
|
cli.dist = Some(answer);
|
||||||
@@ -456,7 +466,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
if cli.series.is_none() {
|
if cli.series.is_none() {
|
||||||
match crate::distro_info::get_ordered_series_name(&dist).await {
|
match crate::distro_info::get_ordered_series_name(&dist).await {
|
||||||
Ok(series) if !series.is_empty() => {
|
Ok(series) if !series.is_empty() => {
|
||||||
let answer = prompt::select(SERIES_LABEL, &series, &series[0])?;
|
let answer = prompter.select(SERIES_LABEL, &series, &series[0])?;
|
||||||
cli.series = Some(answer);
|
cli.series = Some(answer);
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -472,6 +482,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
if template == TemplateId::EMPTY && cli.depends.is_empty() {
|
if template == TemplateId::EMPTY && cli.depends.is_empty() {
|
||||||
let validate = |answer: &str| options::validate_depends(answer).map(|_| ());
|
let validate = |answer: &str| options::validate_depends(answer).map(|_| ());
|
||||||
let answer = ask_text(
|
let answer = ask_text(
|
||||||
|
prompter,
|
||||||
"Depends (metapackage, comma-separated, blank for an empty base)",
|
"Depends (metapackage, comma-separated, blank for an empty base)",
|
||||||
"",
|
"",
|
||||||
validate,
|
validate,
|
||||||
@@ -494,7 +505,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
// `--no-git` already declined, or there is nothing to initialize.
|
// `--no-git` already declined, or there is nothing to initialize.
|
||||||
cli.git = false;
|
cli.git = false;
|
||||||
} else {
|
} else {
|
||||||
cli.git = prompt::confirm("Initialize a git repository?", cli.git)?;
|
cli.git = prompter.confirm("Initialize a git repository?", cli.git)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve through the same pipeline as the non-interactive path: one
|
// Resolve through the same pipeline as the non-interactive path: one
|
||||||
@@ -505,7 +516,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
// build resolve libraries through pkg-config? The project files prefill
|
// build resolve libraries through pkg-config? The project files prefill
|
||||||
// the default (dependency() / pkg_check_modules calls found).
|
// the default (dependency() / pkg_check_modules calls found).
|
||||||
if matches!(template, TemplateId::MESON | TemplateId::CMAKE)
|
if matches!(template, TemplateId::MESON | TemplateId::CMAKE)
|
||||||
&& prompt::confirm(
|
&& prompter.confirm(
|
||||||
"Does the build resolve libraries through pkg-config (add it to Build-Depends)?",
|
"Does the build resolve libraries through pkg-config (add it to Build-Depends)?",
|
||||||
pkg_config_hint(&detect_dir, template),
|
pkg_config_hint(&detect_dir, template),
|
||||||
)?
|
)?
|
||||||
@@ -515,7 +526,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
|
|
||||||
// Wizard-only extras (default off).
|
// Wizard-only extras (default off).
|
||||||
if template != TemplateId::EMPTY
|
if template != TemplateId::EMPTY
|
||||||
&& prompt::confirm(
|
&& prompter.confirm(
|
||||||
"Add an autopkgtest smoke test (debian/tests/control)?",
|
"Add an autopkgtest smoke test (debian/tests/control)?",
|
||||||
false,
|
false,
|
||||||
)?
|
)?
|
||||||
@@ -523,15 +534,15 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
opts.autopkgtest = true;
|
opts.autopkgtest = true;
|
||||||
}
|
}
|
||||||
if let Some(watch) = watch_template(opts.homepage.as_deref())
|
if let Some(watch) = watch_template(opts.homepage.as_deref())
|
||||||
&& prompt::confirm("Add a debian/watch release watcher?", false)?
|
&& prompter.confirm("Add a debian/watch release watcher?", false)?
|
||||||
{
|
{
|
||||||
opts.watch = Some(watch);
|
opts.watch = Some(watch);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary screen + final confirmation: Ctrl+C or 'n' abort with
|
// Summary screen + final confirmation: Ctrl+C or 'n' abort with
|
||||||
// nothing written (generation is all-or-nothing later anyway).
|
// nothing written (generation is all-or-nothing later anyway).
|
||||||
println!("{}", summary_text(&opts, toolchain_pin.as_deref()));
|
prompter.present(&summary_text(&opts, toolchain_pin.as_deref()));
|
||||||
if !prompt::confirm("Generate?", true)? {
|
if !prompter.confirm("Generate?", true)? {
|
||||||
return Err("Aborted: nothing was written to disk.".into());
|
return Err("Aborted: nothing was written to disk.".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,9 +564,10 @@ pub async fn offer_verification(
|
|||||||
outcome: &ScaffoldOutcome,
|
outcome: &ScaffoldOutcome,
|
||||||
multi: &MultiProgress,
|
multi: &MultiProgress,
|
||||||
no_verify: bool,
|
no_verify: bool,
|
||||||
|
prompter: &dyn Prompter,
|
||||||
) {
|
) {
|
||||||
let tree = opts.target_dir(&std::env::current_dir().unwrap_or_default());
|
let tree = opts.target_dir(&std::env::current_dir().unwrap_or_default());
|
||||||
let display = crate::ui::display_path(&tree);
|
let display = crate::report::display_path(&tree);
|
||||||
let display = if display.is_empty() {
|
let display = if display.is_empty() {
|
||||||
".".to_string()
|
".".to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -565,7 +577,7 @@ pub async fn offer_verification(
|
|||||||
if outcome.vendoring_failed {
|
if outcome.vendoring_failed {
|
||||||
// Set apart from the surrounding success output by blank lines: a
|
// Set apart from the surrounding success output by blank lines: a
|
||||||
// single warning between two success lines is easy to miss.
|
// single warning between two success lines is easy to miss.
|
||||||
println!();
|
prompter.present("");
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"The Cargo dependencies could NOT be vendored: this package will \
|
"The Cargo dependencies could NOT be vendored: this package will \
|
||||||
not build until the vendoring is completed by hand:\n\
|
not build until the vendoring is completed by hand:\n\
|
||||||
@@ -573,10 +585,10 @@ pub async fn offer_verification(
|
|||||||
\x20 2. add the printed source replacement to .cargo/config.toml, \
|
\x20 2. add the printed source replacement to .cargo/config.toml, \
|
||||||
plus `[net] offline = true`"
|
plus `[net] offline = true`"
|
||||||
);
|
);
|
||||||
println!();
|
prompter.present("");
|
||||||
}
|
}
|
||||||
|
|
||||||
if no_verify || !is_interactive() {
|
if no_verify || !prompter.interactive() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,7 +597,7 @@ pub async fn offer_verification(
|
|||||||
} else {
|
} else {
|
||||||
"Verify with `pkh build` now?"
|
"Verify with `pkh build` now?"
|
||||||
};
|
};
|
||||||
let verify_source = match prompt::confirm(build_offer, !outcome.vendoring_failed) {
|
let verify_source = match prompter.confirm(build_offer, !outcome.vendoring_failed) {
|
||||||
Ok(answer) => answer,
|
Ok(answer) => answer,
|
||||||
Err(_) => return,
|
Err(_) => return,
|
||||||
};
|
};
|
||||||
@@ -593,12 +605,13 @@ pub async fn offer_verification(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ui = Some(std::sync::Arc::new(crate::ui::deb::DebUi::new(multi)));
|
let ui = std::sync::Arc::new(crate::ui::deb::DebUi::new(multi));
|
||||||
if let Err(e) = crate::build::build_source_package(
|
if let Err(e) = crate::build::build_source_package(crate::build::BuildSourceOptions {
|
||||||
Some(&tree),
|
source: Some(tree.clone()),
|
||||||
crate::build::SourceBuildOptions::default(),
|
options: crate::build::SourceBuildOptions::default(),
|
||||||
ui,
|
view: &*ui,
|
||||||
) {
|
prompter,
|
||||||
|
}) {
|
||||||
log::error!("Verification source build failed: {e}");
|
log::error!("Verification source build failed: {e}");
|
||||||
log::info!(
|
log::info!(
|
||||||
"The scaffolded tree is intact. Inspect it, then retry with \
|
"The scaffolded tree is intact. Inspect it, then retry with \
|
||||||
@@ -612,7 +625,7 @@ pub async fn offer_verification(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let verify_deb = match prompt::confirm(
|
let verify_deb = match prompter.confirm(
|
||||||
"Verify with `pkh deb` now? (needs network + build deps)",
|
"Verify with `pkh deb` now? (needs network + build deps)",
|
||||||
false,
|
false,
|
||||||
) {
|
) {
|
||||||
@@ -623,20 +636,13 @@ pub async fn offer_verification(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ui = Some(std::sync::Arc::new(crate::ui::deb::DebUi::new(multi)));
|
let view = crate::ui::deb::DebUi::new(multi);
|
||||||
if let Err(e) = crate::deb::build_binary_package(
|
if let Err(e) = crate::deb::build_binary_package(crate::deb::DebBuildOptions {
|
||||||
None,
|
series: Some(opts.series.clone()),
|
||||||
Some(&opts.series),
|
cwd: Some(tree.clone()),
|
||||||
None,
|
view: &view,
|
||||||
Some(&tree),
|
..Default::default()
|
||||||
false,
|
})
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
ui,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
log::error!("Verification binary build failed: {e}");
|
log::error!("Verification binary build failed: {e}");
|
||||||
@@ -803,9 +809,13 @@ fn license_question_default(probe_license: Option<&str>) -> (String, String) {
|
|||||||
|
|
||||||
/// Ask the language question until a known template label (or CLI
|
/// Ask the language question until a known template label (or CLI
|
||||||
/// identifier) is answered — the selector allows typing arbitrary text.
|
/// identifier) is answered — the selector allows typing arbitrary text.
|
||||||
fn select_template(options: &[String], default: &str) -> Result<TemplateId, Box<dyn Error>> {
|
fn select_template(
|
||||||
|
prompter: &dyn Prompter,
|
||||||
|
options: &[String],
|
||||||
|
default: &str,
|
||||||
|
) -> Result<TemplateId, Box<dyn Error>> {
|
||||||
loop {
|
loop {
|
||||||
let answer = prompt::select(LANGUAGE_LABEL, options, default)?;
|
let answer = prompter.select(LANGUAGE_LABEL, options, default)?;
|
||||||
match TemplateId::from_label(&answer) {
|
match TemplateId::from_label(&answer) {
|
||||||
Some(id) => return Ok(id),
|
Some(id) => return Ok(id),
|
||||||
None => log::warn!(
|
None => log::warn!(
|
||||||
@@ -819,13 +829,14 @@ fn select_template(options: &[String], default: &str) -> Result<TemplateId, Box<
|
|||||||
/// Ask a `select` question until `accept` holds for the answer (the
|
/// Ask a `select` question until `accept` holds for the answer (the
|
||||||
/// selector allows typing arbitrary text, which callers may need to reject).
|
/// selector allows typing arbitrary text, which callers may need to reject).
|
||||||
fn select_from(
|
fn select_from(
|
||||||
|
prompter: &dyn Prompter,
|
||||||
label: &str,
|
label: &str,
|
||||||
options: &[String],
|
options: &[String],
|
||||||
default: &str,
|
default: &str,
|
||||||
accept: impl Fn(&str) -> bool,
|
accept: impl Fn(&str) -> bool,
|
||||||
) -> Result<String, Box<dyn Error>> {
|
) -> Result<String, Box<dyn Error>> {
|
||||||
loop {
|
loop {
|
||||||
let answer = prompt::select(label, options, default)?;
|
let answer = prompter.select(label, options, default)?;
|
||||||
if accept(&answer) {
|
if accept(&answer) {
|
||||||
return Ok(answer);
|
return Ok(answer);
|
||||||
}
|
}
|
||||||
@@ -838,7 +849,7 @@ fn select_from(
|
|||||||
/// upstream version like `1.0-2` carrying a Debian revision) is withheld —
|
/// upstream version like `1.0-2` carrying a Debian revision) is withheld —
|
||||||
/// the question is asked without a default instead of offering one that
|
/// the question is asked without a default instead of offering one that
|
||||||
/// Enter would accept verbatim.
|
/// Enter would accept verbatim.
|
||||||
fn offered_default<'a>(default: &'a str, validate: &prompt::Validator) -> &'a str {
|
fn offered_default<'a>(default: &'a str, validate: &Validator) -> &'a str {
|
||||||
if default.is_empty() || validate(default).is_ok() {
|
if default.is_empty() || validate(default).is_ok() {
|
||||||
default
|
default
|
||||||
} else {
|
} else {
|
||||||
@@ -850,11 +861,7 @@ fn offered_default<'a>(default: &'a str, validate: &prompt::Validator) -> &'a st
|
|||||||
/// `default`, and whatever answer is finally proposed — typed or the
|
/// `default`, and whatever answer is finally proposed — typed or the
|
||||||
/// default — must pass `validate`. `Err` carries the validation error so the
|
/// default — must pass `validate`. `Err` carries the validation error so the
|
||||||
/// caller re-asks with it.
|
/// caller re-asks with it.
|
||||||
fn accept_answer(
|
fn accept_answer(answer: &str, default: &str, validate: &Validator) -> Result<String, String> {
|
||||||
answer: &str,
|
|
||||||
default: &str,
|
|
||||||
validate: &prompt::Validator,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let answer = if answer.is_empty() { default } else { answer };
|
let answer = if answer.is_empty() { default } else { answer };
|
||||||
validate(answer).map(|_| answer.to_string())
|
validate(answer).map(|_| answer.to_string())
|
||||||
}
|
}
|
||||||
@@ -868,6 +875,7 @@ fn accept_answer(
|
|||||||
/// validation error) — probe data can never bypass validation and only blow
|
/// validation error) — probe data can never bypass validation and only blow
|
||||||
/// up later in [`options::resolve`].
|
/// up later in [`options::resolve`].
|
||||||
fn ask_text(
|
fn ask_text(
|
||||||
|
prompter: &dyn Prompter,
|
||||||
label: &str,
|
label: &str,
|
||||||
default: &str,
|
default: &str,
|
||||||
validate: impl Fn(&str) -> Result<(), String> + 'static,
|
validate: impl Fn(&str) -> Result<(), String> + 'static,
|
||||||
@@ -889,7 +897,7 @@ fn ask_text(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
loop {
|
loop {
|
||||||
let answer = prompt::text(label, default, Some(&accept_empty))?;
|
let answer = prompter.text(label, default, Some(&accept_empty))?;
|
||||||
// Typed non-empty answers were already validated by the prompt; only
|
// Typed non-empty answers were already validated by the prompt; only
|
||||||
// an empty one resolves to the default, pre-decided above.
|
// an empty one resolves to the default, pre-decided above.
|
||||||
let answer = if answer.is_empty() {
|
let answer = if answer.is_empty() {
|
||||||
@@ -1551,7 +1559,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn select_labels_carry_their_own_separator() {
|
fn select_labels_carry_their_own_separator() {
|
||||||
// prompt::select renders `> <label><answer>` verbatim; a label
|
// the selector renders `> <label><answer>` verbatim; a label
|
||||||
// without a trailing separator glues the answer to the prompt
|
// without a trailing separator glues the answer to the prompt
|
||||||
// (regression: the wizard once rendered "> LicenseMIT").
|
// (regression: the wizard once rendered "> LicenseMIT").
|
||||||
for label in SELECT_LABELS {
|
for label in SELECT_LABELS {
|
||||||
|
|||||||
@@ -9,6 +9,22 @@ use crate::apt::release::{self, VerifiedRelease};
|
|||||||
use crossterm::style::Stylize;
|
use crossterm::style::Stylize;
|
||||||
use log::{debug, warn};
|
use log::{debug, warn};
|
||||||
|
|
||||||
|
/// Split a PPA reference into its `(user, name)` parts
|
||||||
|
///
|
||||||
|
/// A PPA is written `user/ppa_name` (e.g. `user/my-ppa`); anything else —
|
||||||
|
/// more segments, empty parts — is a format error carrying the canonical
|
||||||
|
/// message.
|
||||||
|
pub fn split_ppa(ppa: &str) -> Result<(&str, &str), String> {
|
||||||
|
let parts: Vec<&str> = ppa.split('/').collect();
|
||||||
|
if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
|
||||||
|
Ok((parts[0], parts[1]))
|
||||||
|
} else {
|
||||||
|
Err(format!(
|
||||||
|
"Invalid PPA format: '{ppa}'. Expected: user/ppa_name"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert a PPA specification to a base URL
|
/// Convert a PPA specification to a base URL
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
@@ -943,6 +959,23 @@ pub async fn lookup(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// `user/ppa_name` splits into its two parts.
|
||||||
|
#[test]
|
||||||
|
fn split_ppa_parses_the_canonical_form() {
|
||||||
|
assert_eq!(split_ppa("user/my-ppa"), Ok(("user", "my-ppa")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anything but exactly two non-empty segments is rejected, with the
|
||||||
|
/// canonical message.
|
||||||
|
#[test]
|
||||||
|
fn split_ppa_rejects_malformed_references() {
|
||||||
|
for bad in ["", "user", "user/", "/ppa", "a/b/c"] {
|
||||||
|
let err = split_ppa(bad).unwrap_err();
|
||||||
|
assert!(err.contains("Invalid PPA format"), "{err}");
|
||||||
|
assert!(err.contains(bad), "{err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Serve canned byte responses on a local port, one per connection (the
|
/// Serve canned byte responses on a local port, one per connection (the
|
||||||
/// last response repeats), and return the base URL
|
/// last response repeats), and return the base URL
|
||||||
///
|
///
|
||||||
|
|||||||
+43
-84
@@ -18,7 +18,6 @@ pub mod target;
|
|||||||
use std::cmp::Ordering;
|
use std::cmp::Ordering;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use indicatif::{MultiProgress, ProgressBar};
|
|
||||||
use log::info;
|
use log::info;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -27,10 +26,10 @@ use crate::debian::checksums::FileChecksums;
|
|||||||
use crate::debian::control::ControlInfo;
|
use crate::debian::control::ControlInfo;
|
||||||
use crate::debian::version::DebianVersion;
|
use crate::debian::version::DebianVersion;
|
||||||
use crate::launchpad;
|
use crate::launchpad;
|
||||||
use crate::ui;
|
use crate::report::{BuildTarget, BuildView, Prompter};
|
||||||
|
|
||||||
/// Everything `put` needs to run.
|
/// Everything `put` needs to run.
|
||||||
pub struct PutOptions {
|
pub struct PutOptions<'a> {
|
||||||
/// PPA to upload to, `user/ppa_name` format.
|
/// PPA to upload to, `user/ppa_name` format.
|
||||||
pub ppa: String,
|
pub ppa: String,
|
||||||
/// Explicit `.changes` file to upload; when `None`, the one matching the
|
/// Explicit `.changes` file to upload; when `None`, the one matching the
|
||||||
@@ -42,14 +41,25 @@ pub struct PutOptions {
|
|||||||
pub force: bool,
|
pub force: bool,
|
||||||
/// Source package directory (the one containing `debian/`).
|
/// Source package directory (the one containing `debian/`).
|
||||||
pub cwd: PathBuf,
|
pub cwd: PathBuf,
|
||||||
|
/// Where the upload progress (status messages, per-file byte counts) is
|
||||||
|
/// reported.
|
||||||
|
pub view: &'a dyn BuildView,
|
||||||
|
/// Who answers the host-key question on first contact with the target
|
||||||
|
/// server (fail-closed when nobody can be asked).
|
||||||
|
pub prompter: &'a dyn Prompter,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Upload the package described by `opts` to its target through `multi`'s
|
/// Upload the package described by `opts` to its target, reporting progress
|
||||||
/// progress bars.
|
/// through the view and asking the prompter when the server is unknown.
|
||||||
pub async fn put(
|
pub async fn put(opts: &PutOptions<'_>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
opts: &PutOptions,
|
// The display is released on every path (success and early `?` bails)
|
||||||
multi: &MultiProgress,
|
// before the outcome is logged, so no stale status line lingers above it
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
let result = put_impl(opts).await;
|
||||||
|
opts.view.suspend();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put_impl(opts: &PutOptions<'_>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let target = launchpad::ppa_target(&opts.ppa)?;
|
let target = launchpad::ppa_target(&opts.ppa)?;
|
||||||
|
|
||||||
let ssh_config = ssh::lookup_ssh_config(&target.fqdn);
|
let ssh_config = ssh::lookup_ssh_config(&target.fqdn);
|
||||||
@@ -79,21 +89,20 @@ pub async fn put(
|
|||||||
};
|
};
|
||||||
let changes = changes::parse(&changes_path)?;
|
let changes = changes::parse(&changes_path)?;
|
||||||
|
|
||||||
// The summary line stays up for the whole flow: the completion message
|
// The summary line stays up for the whole flow: rendered as the view's
|
||||||
// replaces it on success, and the `PutBars` guard clears it on every
|
// persistent status, with the per-step messages below it
|
||||||
// early `?` bail so the error logged by main is not preceded by stale
|
opts.view.target(BuildTarget {
|
||||||
// bars
|
package: &changes.source,
|
||||||
let mut bars = PutBars::default();
|
version: &changes.version,
|
||||||
let summary = multi.add(ProgressBar::new(0));
|
target: &target.label,
|
||||||
// Style and prefix go in before the steady tick: otherwise the first
|
display: format!(
|
||||||
// tick can render one frame with the default bar template
|
"Uploading {} {} to {}",
|
||||||
summary.set_style(ui::spinner_style());
|
changes.source, changes.version, target.label
|
||||||
summary.set_prefix(format!(
|
),
|
||||||
"Uploading {} {} to {}",
|
source_only: false,
|
||||||
changes.source, changes.version, target.label
|
// An upload runs no subprocess: nothing to tee
|
||||||
));
|
tee_log: false,
|
||||||
summary.enable_steady_tick(TICK);
|
});
|
||||||
bars.track(summary.clone());
|
|
||||||
changes::validate(&changes)?;
|
changes::validate(&changes)?;
|
||||||
|
|
||||||
// Pre-flight checks for everything the upload queue only rejects after
|
// Pre-flight checks for everything the upload queue only rejects after
|
||||||
@@ -109,14 +118,10 @@ pub async fn put(
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
let checking = multi.add(ProgressBar::new(0));
|
opts.view
|
||||||
checking.set_style(ui::spinner_style());
|
.message(&format!("Checking {} on Launchpad...", target.label));
|
||||||
checking.set_prefix(format!("Checking {} on Launchpad...", target.label));
|
|
||||||
checking.enable_steady_tick(TICK);
|
|
||||||
bars.track(checking.clone());
|
|
||||||
launchpad::ppa_info(&opts.ppa).await?;
|
launchpad::ppa_info(&opts.ppa).await?;
|
||||||
launchpad::check_ppa_series(&changes.distribution).await?;
|
launchpad::check_ppa_series(&changes.distribution).await?;
|
||||||
clear_bar(&checking);
|
|
||||||
|
|
||||||
// Last pre-flight check: a version the PPA already publishes at or
|
// Last pre-flight check: a version the PPA already publishes at or
|
||||||
// above the changes' one would supersede (or reject) this upload
|
// above the changes' one would supersede (or reject) this upload
|
||||||
@@ -134,14 +139,10 @@ pub async fn put(
|
|||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let connecting = multi.add(ProgressBar::new(0));
|
opts.view
|
||||||
connecting.set_style(ui::spinner_style());
|
.message(&format!("Connecting to {login}@{host}:{port}..."));
|
||||||
connecting.set_prefix(format!("Connecting to {login}@{host}:{port}..."));
|
let session = ssh::connect(&host, port, &login, &ssh_config, opts.prompter)?;
|
||||||
connecting.enable_steady_tick(TICK);
|
|
||||||
bars.track(connecting.clone());
|
|
||||||
let session = ssh::connect(&host, port, &login, &ssh_config)?;
|
|
||||||
let sftp = ssh::sftp(&session)?;
|
let sftp = ssh::sftp(&session)?;
|
||||||
clear_bar(&connecting);
|
|
||||||
|
|
||||||
// Payload first, the .changes file last (like dput), so the server-side
|
// Payload first, the .changes file last (like dput), so the server-side
|
||||||
// queue processor can never pick up an incomplete upload
|
// queue processor can never pick up an incomplete upload
|
||||||
@@ -179,15 +180,11 @@ pub async fn put(
|
|||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Same transfer view as pull: prefix line, bar on its own line
|
|
||||||
let bar = multi.add(ProgressBar::new(size));
|
|
||||||
bar.enable_steady_tick(std::time::Duration::from_millis(50));
|
|
||||||
bar.set_style(ui::transfer_style());
|
|
||||||
bar.set_prefix(format!("Uploading {name}..."));
|
|
||||||
|
|
||||||
let remote = format!("{incoming}/{name}");
|
let remote = format!("{incoming}/{name}");
|
||||||
let result = ssh::upload_file(&sftp, path, &remote, &host, &bar);
|
let label = format!("Uploading {name}");
|
||||||
bar.finish_and_clear();
|
let view = opts.view;
|
||||||
|
let on_progress = |uploaded: u64| view.progress(&label, uploaded as usize, size as usize);
|
||||||
|
let result = ssh::upload_file(&sftp, path, &remote, &host, &on_progress);
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
// The failed file itself joins the cleanup: its remote `create`
|
// The failed file itself joins the cleanup: its remote `create`
|
||||||
// may have succeeded before the failure, leaving a partial — or,
|
// may have succeeded before the failure, leaving a partial — or,
|
||||||
@@ -204,9 +201,6 @@ pub async fn put(
|
|||||||
// replaying is safe).
|
// replaying is safe).
|
||||||
record_upload(&upload_log_path()?, &record)?;
|
record_upload(&upload_log_path()?, &record)?;
|
||||||
|
|
||||||
// The completion lines replace the summary bar; the guard's own clear
|
|
||||||
// at scope exit is a no-op for the already-finished bars
|
|
||||||
clear_bar(&summary);
|
|
||||||
info!(
|
info!(
|
||||||
"Upload of {} {} to {} complete.",
|
"Upload of {} {} to {} complete.",
|
||||||
changes.source, changes.version, target.label
|
changes.source, changes.version, target.label
|
||||||
@@ -260,41 +254,6 @@ fn cleanup_partial_upload(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Steady tick interval of every bar rendered by a `put` run
|
|
||||||
const TICK: std::time::Duration = std::time::Duration::from_millis(50);
|
|
||||||
|
|
||||||
/// Stop `bar`'s steady tick and clear it from the terminal. The tick is
|
|
||||||
/// disabled first: a tick firing right after the clear would redraw a stale
|
|
||||||
/// frame, the race [`crate::ui::deb::DebUi::suspend`] guards against too.
|
|
||||||
/// Clearing twice is harmless: finished bars stay finished.
|
|
||||||
fn clear_bar(bar: &ProgressBar) {
|
|
||||||
bar.disable_steady_tick();
|
|
||||||
bar.finish_and_clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The bars rendered by one `put` run, cleared when the guard drops. The
|
|
||||||
/// error paths bail out early through `?` and `main` logs the error
|
|
||||||
/// afterwards, so a bar left unfinished would linger on screen above it.
|
|
||||||
#[derive(Default)]
|
|
||||||
struct PutBars {
|
|
||||||
bars: Vec<ProgressBar>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PutBars {
|
|
||||||
/// Track `bar` so it is cleared with the rest when the guard drops
|
|
||||||
fn track(&mut self, bar: ProgressBar) {
|
|
||||||
self.bars.push(bar);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for PutBars {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
for bar in &self.bars {
|
|
||||||
clear_bar(bar);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate the source package's `Section` (debian/control source stanza)
|
/// Validate the source package's `Section` (debian/control source stanza)
|
||||||
/// against the distribution's valid sections: a bare section or a
|
/// against the distribution's valid sections: a bare section or a
|
||||||
/// `section/subsection` is accepted. Archives reject uploads carrying an
|
/// `section/subsection` is accepted. Archives reject uploads carrying an
|
||||||
@@ -521,7 +480,7 @@ fn discover_changes(cwd: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
|||||||
Pass the one to upload explicitly",
|
Pass the one to upload explicitly",
|
||||||
entry.source,
|
entry.source,
|
||||||
many.iter()
|
many.iter()
|
||||||
.map(|p| ui::display_path(p))
|
.map(|p| crate::report::display_path(p))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ")
|
.join(", ")
|
||||||
)
|
)
|
||||||
|
|||||||
+9
-11
@@ -20,14 +20,13 @@ use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use indicatif::ProgressBar;
|
|
||||||
use log::debug;
|
use log::debug;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use ssh2::{CheckResult, HostKeyType, KnownHostFileKind, KnownHosts, Session};
|
use ssh2::{CheckResult, HostKeyType, KnownHostFileKind, KnownHosts, Session};
|
||||||
|
|
||||||
use crate::data::embed_data;
|
use crate::data::embed_data;
|
||||||
use crate::ui::prompt;
|
use crate::report::Prompter;
|
||||||
|
|
||||||
/// Pinned SSH host key fingerprints, loaded from the bundled
|
/// Pinned SSH host key fingerprints, loaded from the bundled
|
||||||
/// `host_keys.yml` data file (same pattern as `distro_info.yml`): data
|
/// `host_keys.yml` data file (same pattern as `distro_info.yml`): data
|
||||||
@@ -303,6 +302,7 @@ pub fn connect(
|
|||||||
port: u16,
|
port: u16,
|
||||||
login: &str,
|
login: &str,
|
||||||
config: &SshConfig,
|
config: &SshConfig,
|
||||||
|
prompter: &dyn Prompter,
|
||||||
) -> Result<Session, Box<dyn std::error::Error>> {
|
) -> Result<Session, Box<dyn std::error::Error>> {
|
||||||
let tcp = tcp_connect(host, port)?;
|
let tcp = tcp_connect(host, port)?;
|
||||||
|
|
||||||
@@ -326,7 +326,7 @@ pub fn connect(
|
|||||||
let (key, key_type) = session
|
let (key, key_type) = session
|
||||||
.host_key()
|
.host_key()
|
||||||
.ok_or_else(|| format!("{host} offered no host key"))?;
|
.ok_or_else(|| format!("{host} offered no host key"))?;
|
||||||
verify_host_key(host, port, key, key_type)?;
|
verify_host_key(host, port, key, key_type, prompter)?;
|
||||||
|
|
||||||
authenticate(&session, host, login, config)?;
|
authenticate(&session, host, login, config)?;
|
||||||
|
|
||||||
@@ -353,6 +353,7 @@ fn verify_host_key(
|
|||||||
port: u16,
|
port: u16,
|
||||||
key: &[u8],
|
key: &[u8],
|
||||||
key_type: HostKeyType,
|
key_type: HostKeyType,
|
||||||
|
prompter: &dyn Prompter,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let fingerprint = fingerprint(key);
|
let fingerprint = fingerprint(key);
|
||||||
|
|
||||||
@@ -387,12 +388,7 @@ fn verify_host_key(
|
|||||||
format!("[{host}]:{port}")
|
format!("[{host}]:{port}")
|
||||||
};
|
};
|
||||||
|
|
||||||
// The banner is plain output: the confirmation prompt itself
|
if !prompter.accept_host_key(&display, key_type_desc, &fingerprint) {
|
||||||
// must stay a single line for its redraw logic
|
|
||||||
println!("The authenticity of host '{display}' can't be established.");
|
|
||||||
println!("{key_type_desc} key fingerprint is {fingerprint}.");
|
|
||||||
let accepted = prompt::confirm("Accept and store this host key?", false)?;
|
|
||||||
if !accepted {
|
|
||||||
return Err(format!("Host key for {display} rejected, aborting upload").into());
|
return Err(format!("Host key for {display} rejected, aborting upload").into());
|
||||||
}
|
}
|
||||||
if let Some(name) = key_type_name(key_type) {
|
if let Some(name) = key_type_name(key_type) {
|
||||||
@@ -592,7 +588,7 @@ pub fn upload_file(
|
|||||||
local: &Path,
|
local: &Path,
|
||||||
remote: &str,
|
remote: &str,
|
||||||
host: &str,
|
host: &str,
|
||||||
bar: &ProgressBar,
|
on_progress: &dyn Fn(u64),
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let mut local_file =
|
let mut local_file =
|
||||||
fs::File::open(local).map_err(|e| format!("cannot open '{}': {}", local.display(), e))?;
|
fs::File::open(local).map_err(|e| format!("cannot open '{}': {}", local.display(), e))?;
|
||||||
@@ -602,6 +598,7 @@ pub fn upload_file(
|
|||||||
.map_err(|e| format!("cannot create remote file {remote} on {host}: {e}"))?;
|
.map_err(|e| format!("cannot create remote file {remote} on {host}: {e}"))?;
|
||||||
|
|
||||||
let mut buf = [0u8; 32 * 1024];
|
let mut buf = [0u8; 32 * 1024];
|
||||||
|
let mut uploaded: u64 = 0;
|
||||||
loop {
|
loop {
|
||||||
let n = local_file.read(&mut buf)?;
|
let n = local_file.read(&mut buf)?;
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
@@ -610,7 +607,8 @@ pub fn upload_file(
|
|||||||
remote_file
|
remote_file
|
||||||
.write_all(&buf[..n])
|
.write_all(&buf[..n])
|
||||||
.map_err(|e| format!("failed uploading to {remote} on {host}: {e}"))?;
|
.map_err(|e| format!("failed uploading to {remote} on {host}: {e}"))?;
|
||||||
bar.inc(n as u64);
|
uploaded += n as u64;
|
||||||
|
on_progress(uploaded);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close explicitly: quota-exceeded and similar failures only surface in
|
// Close explicitly: quota-exceeded and similar failures only surface in
|
||||||
|
|||||||
+207
@@ -0,0 +1,207 @@
|
|||||||
|
//! Environment-agnostic reporting ports.
|
||||||
|
//!
|
||||||
|
//! The core flows report progress and ask questions exclusively through the
|
||||||
|
//! traits in this module, so the same pipeline can drive a terminal live
|
||||||
|
//! view, a headless library consumer, or a remote frontend (e.g. a builder
|
||||||
|
//! server forwarding build events to a web UI over server-sent events): every
|
||||||
|
//! event carries plain data — strings, numbers, paths — with no terminal,
|
||||||
|
//! styling or locale assumptions. Adapters decide how events reach the user:
|
||||||
|
//! the terminal live view ([`crate::ui::deb::DebUi`]) renders them in place,
|
||||||
|
//! while another embedding maps each method onto its own wire format.
|
||||||
|
//!
|
||||||
|
//! Every [`BuildView`] method defaults to doing nothing, so implementations
|
||||||
|
//! only override the events they care about; [`Quiet`] provides the inert
|
||||||
|
//! implementations used by headless runs and tests. [`Prompter`] is
|
||||||
|
//! deliberately blocking: an implementation may round-trip each question to
|
||||||
|
//! a remote user, as long as it eventually answers (or takes the default).
|
||||||
|
//! Asking can fail (the user cancels, the connection drops); flows
|
||||||
|
//! propagate the error, which aborts them.
|
||||||
|
|
||||||
|
use std::error::Error;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::context::LineSink;
|
||||||
|
use crate::logfmt::Classifier;
|
||||||
|
|
||||||
|
/// Answer validator of [`Prompter::text`]: accepts the answer, or explains
|
||||||
|
/// why it is rejected (the implementation re-asks with the explanation).
|
||||||
|
pub type Validator = dyn Fn(&str) -> Result<(), String>;
|
||||||
|
|
||||||
|
/// Identity of the build whose events follow, as announced through
|
||||||
|
/// [`BuildView::target`].
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BuildTarget<'a> {
|
||||||
|
/// Source package name (e.g. `hello`).
|
||||||
|
pub package: &'a str,
|
||||||
|
/// Full version being built (e.g. `2.10-3`).
|
||||||
|
pub version: &'a str,
|
||||||
|
/// What the build targets: a distribution series, optionally with an
|
||||||
|
/// architecture (`noble`, `sid`, `noble/arm64`), or the upload target
|
||||||
|
/// label.
|
||||||
|
pub target: &'a str,
|
||||||
|
/// Ready-to-render status line for display adapters, composed by the
|
||||||
|
/// flow (e.g. "Building source package hello (2.10-3) for unstable",
|
||||||
|
/// "Uploading hello (2.10-3) to ppa:user/ppa"): the wording is the
|
||||||
|
/// flow's, adapters render it verbatim.
|
||||||
|
pub display: String,
|
||||||
|
/// Whether this is a source-only build (producing a `.dsc`); names the
|
||||||
|
/// terminal adapter's tee log (`build-*.log` vs `deb-*.log`).
|
||||||
|
pub source_only: bool,
|
||||||
|
/// Whether the view should tee raw subprocess output to its log file.
|
||||||
|
/// Flows without subprocess output (uploads) pass `false` and create
|
||||||
|
/// no log file.
|
||||||
|
pub tee_log: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observer of a running build: target identification, phases, status
|
||||||
|
/// messages, progress and the final outcome.
|
||||||
|
///
|
||||||
|
/// Implement this to observe the [`crate::build`] and [`crate::deb`] flows
|
||||||
|
/// from any frontend. All events arrive in order from the build thread;
|
||||||
|
/// long-lived views are expected to be `Send + Sync` because builds may run
|
||||||
|
/// inside async tasks.
|
||||||
|
pub trait BuildView: Send + Sync {
|
||||||
|
/// The build target was identified; the events that follow belong to it
|
||||||
|
/// (including the earliest subprocess output, e.g. a chroot download).
|
||||||
|
fn target(&self, _target: BuildTarget) {}
|
||||||
|
|
||||||
|
/// A named phase started (e.g. "Applying patches"). `classifier`
|
||||||
|
/// rewrites the phase's raw subprocess lines (see [`crate::logfmt`])
|
||||||
|
/// into display actions and countable progress; views that do not
|
||||||
|
/// rewrite lines locally can ignore it and forward raw lines from
|
||||||
|
/// [`BuildView::sink`] instead.
|
||||||
|
fn phase(&self, _name: &str, _classifier: Box<dyn Classifier>) {}
|
||||||
|
|
||||||
|
/// A status message about in-process work that produces no subprocess
|
||||||
|
/// output (e.g. "Generating .changes").
|
||||||
|
fn message(&self, _text: &str) {}
|
||||||
|
|
||||||
|
/// Determinate progress within the current phase (e.g. artifact
|
||||||
|
/// retrieval); `pos` runs from 0 to `total`.
|
||||||
|
fn progress(&self, _label: &str, _pos: usize, _total: usize) {}
|
||||||
|
|
||||||
|
/// Sink receiving every raw subprocess line while a build command runs,
|
||||||
|
/// when this view consumes the lines itself (live rewriting, tee to a
|
||||||
|
/// log file, forwarding over the network). `None` lets the caller fall
|
||||||
|
/// back to its default handling (e.g. test capture).
|
||||||
|
fn sink(&self) -> Option<Arc<dyn LineSink>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The build succeeded; `artifacts` lists the produced files in
|
||||||
|
/// distribution order (dsc → tarballs → buildinfo → changes).
|
||||||
|
fn finish_success(&self, _artifacts: &[PathBuf]) {}
|
||||||
|
|
||||||
|
/// The build failed; the view should release the display and may report
|
||||||
|
/// diagnostics it collected through [`BuildView::sink`].
|
||||||
|
fn finish_failure(&self) {}
|
||||||
|
|
||||||
|
/// Release the display before writing directly to the shared terminal
|
||||||
|
/// (passthrough diagnostics, cleanup commands that inherit it). The
|
||||||
|
/// release is final for this build; later events may be dropped. A
|
||||||
|
/// no-op for views without a display.
|
||||||
|
fn suspend(&self) {}
|
||||||
|
|
||||||
|
/// Whether this view presents build results to the user by itself;
|
||||||
|
/// callers use this to fall back to plain-line rendering when it does
|
||||||
|
/// not (headless views, verbose mode).
|
||||||
|
fn is_enabled(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answerer of the questions a core flow may ask mid-run.
|
||||||
|
///
|
||||||
|
/// Questions are blocking on purpose: a terminal implementation waits for
|
||||||
|
/// key presses, and a builder-server implementation may forward the question
|
||||||
|
/// to a web client and await the answer on a channel. Implementations that
|
||||||
|
/// cannot ask anyone answer with the question's default.
|
||||||
|
pub trait Prompter: Send + Sync {
|
||||||
|
/// Whether this prompter can interact with a user at all. Flows with an
|
||||||
|
/// interactive and a headless path use this to pick one: a headless run
|
||||||
|
/// takes the defaults or fails with the list of missing answers instead
|
||||||
|
/// of asking question by question.
|
||||||
|
fn interactive(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask a yes/no question. `default` is the answer to take when no user
|
||||||
|
/// can be reached; `Err` means the question was cancelled (Ctrl+C,
|
||||||
|
/// dropped connection) and the flow should abort.
|
||||||
|
fn confirm(&self, _question: &str, default: bool) -> Result<bool, Box<dyn Error>> {
|
||||||
|
Ok(default)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask a one-line selection among `options`, with `default`
|
||||||
|
/// preselected. Selector implementations may also accept typed
|
||||||
|
/// arbitrary text; callers validate the answer and re-ask through the
|
||||||
|
/// same method when they must reject one. `Err` cancels the flow.
|
||||||
|
fn select(
|
||||||
|
&self,
|
||||||
|
_label: &str,
|
||||||
|
_options: &[String],
|
||||||
|
default: &str,
|
||||||
|
) -> Result<String, Box<dyn Error>> {
|
||||||
|
Ok(default.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask a free-text answer. `validate` is applied by the implementation
|
||||||
|
/// so invalid input re-asks at the source (mid-input for a terminal,
|
||||||
|
/// round-trip for a server). `Err` cancels the flow.
|
||||||
|
fn text(
|
||||||
|
&self,
|
||||||
|
_label: &str,
|
||||||
|
default: &str,
|
||||||
|
_validate: Option<&Validator>,
|
||||||
|
) -> Result<String, Box<dyn Error>> {
|
||||||
|
Ok(default.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask whether to accept and store an unverified SSH host key (trust on
|
||||||
|
/// first use): `host` is the display form (`host` or `[host]:port`),
|
||||||
|
/// `key_type` the key type name ("ssh-ed25519", ...) and `fingerprint`
|
||||||
|
/// the human-readable digest. Fail-closed: implementations that cannot
|
||||||
|
/// ask anyone answer `false`, refusing the connection.
|
||||||
|
fn accept_host_key(&self, _host: &str, _key_type: &str, _fingerprint: &str) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Present information to the user outside of any question: the
|
||||||
|
/// scaffold wizard's summary screen, prominent notices around a
|
||||||
|
/// warning. Terminal implementations print the text as-is (unstyled,
|
||||||
|
/// on stdout); server implementations forward it as a display event.
|
||||||
|
/// Implementations that cannot show anything drop it — flows only
|
||||||
|
/// present context that is also available structurally (returned data,
|
||||||
|
/// log records).
|
||||||
|
fn present(&self, _text: &str) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inert view and prompter: drops every event and answers every question
|
||||||
|
/// with its default. The stand-in for headless library runs, verbose mode
|
||||||
|
/// and tests.
|
||||||
|
pub struct Quiet;
|
||||||
|
|
||||||
|
impl BuildView for Quiet {}
|
||||||
|
impl Prompter for Quiet {}
|
||||||
|
|
||||||
|
/// Render a path for terminal display: relative to the current working
|
||||||
|
/// directory when the target lives inside it or directly next to it
|
||||||
|
/// (`../name`, the usual layout of build artifacts), absolute otherwise.
|
||||||
|
///
|
||||||
|
/// Pure formatting shared by the flows that mention paths in their events
|
||||||
|
/// and messages; a remote frontend reproduces it (or not) on its side.
|
||||||
|
pub fn display_path(path: &Path) -> String {
|
||||||
|
let Ok(cwd) = std::env::current_dir() else {
|
||||||
|
return path.display().to_string();
|
||||||
|
};
|
||||||
|
if let Ok(rel) = path.strip_prefix(&cwd) {
|
||||||
|
return rel.display().to_string();
|
||||||
|
}
|
||||||
|
if let Some(parent) = cwd.parent()
|
||||||
|
&& let Ok(rel) = path.strip_prefix(parent)
|
||||||
|
{
|
||||||
|
return format!("../{}", rel.display());
|
||||||
|
}
|
||||||
|
path.display().to_string()
|
||||||
|
}
|
||||||
@@ -3,8 +3,6 @@
|
|||||||
|
|
||||||
/// Live build view for `pkh deb` (status bar + rolling log pane)
|
/// Live build view for `pkh deb` (status bar + rolling log pane)
|
||||||
pub mod deb;
|
pub mod deb;
|
||||||
/// Line classifiers rewriting raw subprocess output for the live views
|
|
||||||
pub mod logfmt;
|
|
||||||
/// Interactive raw-mode prompts: free-text input, option selection and
|
/// Interactive raw-mode prompts: free-text input, option selection and
|
||||||
/// yes/no confirmation
|
/// yes/no confirmation
|
||||||
pub mod prompt;
|
pub mod prompt;
|
||||||
@@ -12,27 +10,8 @@ pub mod prompt;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use indicatif::ProgressDrawTarget;
|
use indicatif::ProgressDrawTarget;
|
||||||
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||||
use std::path::Path;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
/// Render a path for terminal display: relative to the current working
|
|
||||||
/// directory when the target lives inside it or directly next to it
|
|
||||||
/// (`../name`, the usual layout of build artifacts), absolute otherwise.
|
|
||||||
pub fn display_path(path: &Path) -> String {
|
|
||||||
let Ok(cwd) = std::env::current_dir() else {
|
|
||||||
return path.display().to_string();
|
|
||||||
};
|
|
||||||
if let Ok(rel) = path.strip_prefix(&cwd) {
|
|
||||||
return rel.display().to_string();
|
|
||||||
}
|
|
||||||
if let Some(parent) = cwd.parent()
|
|
||||||
&& let Ok(rel) = path.strip_prefix(parent)
|
|
||||||
{
|
|
||||||
return format!("../{}", rel.display());
|
|
||||||
}
|
|
||||||
path.display().to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Style of an unsized operation: spinner and prefix on one line
|
/// Style of an unsized operation: spinner and prefix on one line
|
||||||
pub(crate) fn spinner_style() -> ProgressStyle {
|
pub(crate) fn spinner_style() -> ProgressStyle {
|
||||||
ProgressStyle::default_bar()
|
ProgressStyle::default_bar()
|
||||||
|
|||||||
+79
-147
@@ -3,7 +3,7 @@
|
|||||||
//! ("a terminal in the terminal").
|
//! ("a terminal in the terminal").
|
||||||
//!
|
//!
|
||||||
//! Subprocess output is captured through a [`LineSink`] implementation,
|
//! Subprocess output is captured through a [`LineSink`] implementation,
|
||||||
//! rewritten by classifiers ([`crate::ui::logfmt`]) and rendered in place
|
//! rewritten by classifiers ([`crate::logfmt`]) and rendered in place
|
||||||
//! with indicatif, so pkh's own log lines keep printing above the widget via
|
//! with indicatif, so pkh's own log lines keep printing above the widget via
|
||||||
//! `indicatif-log-bridge`. Every raw captured line is also tee'd to a log
|
//! `indicatif-log-bridge`. Every raw captured line is also tee'd to a log
|
||||||
//! file under the pkh cache directory.
|
//! file under the pkh cache directory.
|
||||||
@@ -21,10 +21,8 @@ use directories::ProjectDirs;
|
|||||||
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||||
|
|
||||||
use crate::context::{LineSink, Stream};
|
use crate::context::{LineSink, Stream};
|
||||||
use crate::ui::logfmt::{
|
use crate::logfmt::{Action, Classifier, GenericClassifier};
|
||||||
Action, AptInstallClassifier, AptUpdateClassifier, Classifier, GenericClassifier,
|
use crate::report::BuildTarget;
|
||||||
MakeClassifier, MmdebstrapClassifier, QuiltClassifier,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Number of lines displayed in the rolling pane
|
/// Number of lines displayed in the rolling pane
|
||||||
const PANE_LINES: usize = 10;
|
const PANE_LINES: usize = 10;
|
||||||
@@ -32,69 +30,6 @@ const PANE_LINES: usize = 10;
|
|||||||
/// Minimum interval between pane redraws
|
/// Minimum interval between pane redraws
|
||||||
const REDRAW_INTERVAL: Duration = Duration::from_millis(50);
|
const REDRAW_INTERVAL: Duration = Duration::from_millis(50);
|
||||||
|
|
||||||
/// Build phases of `pkh deb`, shown in the status bar
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum Phase {
|
|
||||||
/// Downloading the chroot tarball (mmdebstrap)
|
|
||||||
PreparingChroot,
|
|
||||||
/// Extracting the chroot tarball
|
|
||||||
ExtractingChroot,
|
|
||||||
/// Device nodes, /proc bind mount, etc.
|
|
||||||
FinalizingChroot,
|
|
||||||
/// apt-get update
|
|
||||||
UpdatingPackageLists,
|
|
||||||
/// Installing build-essential & co
|
|
||||||
InstallingEssentials,
|
|
||||||
/// quilt push -a
|
|
||||||
ApplyingPatches,
|
|
||||||
/// --inject packages
|
|
||||||
InjectingPackages,
|
|
||||||
/// apt-get build-dep
|
|
||||||
InstallingBuildDeps,
|
|
||||||
/// debian/rules build
|
|
||||||
Building,
|
|
||||||
/// fakeroot debian/rules binary
|
|
||||||
ProducingBinaries,
|
|
||||||
/// Retrieving produced .deb files
|
|
||||||
RetrievingArtifacts,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Phase {
|
|
||||||
/// Human-readable label displayed in the status bar
|
|
||||||
pub fn label(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Phase::PreparingChroot => "Preparing chroot",
|
|
||||||
Phase::ExtractingChroot => "Extracting chroot",
|
|
||||||
Phase::FinalizingChroot => "Finalizing chroot",
|
|
||||||
Phase::UpdatingPackageLists => "Updating package lists",
|
|
||||||
Phase::InstallingEssentials => "Installing essential packages",
|
|
||||||
Phase::ApplyingPatches => "Applying patches",
|
|
||||||
Phase::InjectingPackages => "Injecting packages",
|
|
||||||
Phase::InstallingBuildDeps => "Installing build dependencies",
|
|
||||||
Phase::Building => "Building package",
|
|
||||||
Phase::ProducingBinaries => "Producing binary packages",
|
|
||||||
Phase::RetrievingArtifacts => "Retrieving artifacts",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Default classifier used for a given phase
|
|
||||||
fn default_classifier(phase: Phase) -> Box<dyn Classifier> {
|
|
||||||
match phase {
|
|
||||||
Phase::PreparingChroot => Box::new(MmdebstrapClassifier::new()),
|
|
||||||
Phase::ExtractingChroot | Phase::FinalizingChroot => Box::new(GenericClassifier::new()),
|
|
||||||
Phase::UpdatingPackageLists => Box::new(AptUpdateClassifier::new()),
|
|
||||||
Phase::InstallingEssentials => Box::new(AptInstallClassifier::new("Installing essentials")),
|
|
||||||
Phase::ApplyingPatches => Box::new(QuiltClassifier::new(0)),
|
|
||||||
Phase::InjectingPackages => Box::new(AptInstallClassifier::new("Injecting packages")),
|
|
||||||
Phase::InstallingBuildDeps => {
|
|
||||||
Box::new(AptInstallClassifier::new("Installing build dependencies"))
|
|
||||||
}
|
|
||||||
Phase::Building | Phase::ProducingBinaries => Box::new(MakeClassifier::new()),
|
|
||||||
Phase::RetrievingArtifacts => Box::new(GenericClassifier::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Visual kind of a pane line, driving its color
|
/// Visual kind of a pane line, driving its color
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
enum Kind {
|
enum Kind {
|
||||||
@@ -129,9 +64,9 @@ struct Shared {
|
|||||||
|
|
||||||
/// Live build view for `pkh deb` / `pkh build`
|
/// Live build view for `pkh deb` / `pkh build`
|
||||||
///
|
///
|
||||||
/// Create one per build (disabled automatically when stdout is not a TTY or
|
/// Create one per build (it disables itself automatically when stdout is not
|
||||||
/// when the user requests verbose output), pass it down as
|
/// a TTY) and pass it down through the [`crate::report::BuildView`] port.
|
||||||
/// `Option<Arc<DebUi>>`, and feed subprocess output through [`DebUi::sink`].
|
/// Subprocess output reaches it through [`crate::report::BuildView::sink`].
|
||||||
pub struct DebUi {
|
pub struct DebUi {
|
||||||
shared: Arc<Shared>,
|
shared: Arc<Shared>,
|
||||||
}
|
}
|
||||||
@@ -213,17 +148,6 @@ impl DebUi {
|
|||||||
self.open_log("deb", package, version, &format!("for {series}/{arch}"));
|
self.open_log("deb", package, version, &format!("for {series}/{arch}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Identify the source package being built; names the log file
|
|
||||||
/// (`build-<package>-<version>-<timestamp>.log`) and the status bar
|
|
||||||
pub fn set_build_target(&self, package: &str, version: &str, distribution: &str) {
|
|
||||||
if self.shared.enabled {
|
|
||||||
self.shared.top.set_prefix(format!(
|
|
||||||
"Building source package {package} ({version}) for {distribution}"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
self.open_log("build", package, version, &format!("for {distribution}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rename the placeholder log file to include the build identity
|
/// Rename the placeholder log file to include the build identity
|
||||||
/// (best-effort), then open it so subsequent captured lines are tee'd
|
/// (best-effort), then open it so subsequent captured lines are tee'd
|
||||||
fn open_log(&self, kind: &str, package: &str, version: &str, detail: &str) {
|
fn open_log(&self, kind: &str, package: &str, version: &str, detail: &str) {
|
||||||
@@ -261,20 +185,8 @@ impl DebUi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Switch to a phase, installing its default classifier
|
/// Switch to an arbitrary status label with a custom classifier
|
||||||
pub fn phase(&self, phase: Phase) {
|
fn phase_custom(&self, label: &str, classifier: Box<dyn Classifier>) {
|
||||||
self.phase_custom(phase.label(), default_classifier(phase));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Switch to a phase with a custom classifier (e.g. quilt with a known
|
|
||||||
/// patch count)
|
|
||||||
pub fn phase_with(&self, phase: Phase, classifier: Box<dyn Classifier>) {
|
|
||||||
self.phase_custom(phase.label(), classifier);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Switch to an arbitrary status label with a custom classifier; used by
|
|
||||||
/// flows whose phases are not part of [`Phase`] (e.g. source builds)
|
|
||||||
pub fn phase_custom(&self, label: &str, classifier: Box<dyn Classifier>) {
|
|
||||||
{
|
{
|
||||||
let mut st = self.shared.state.lock().unwrap();
|
let mut st = self.shared.state.lock().unwrap();
|
||||||
st.classifier = classifier;
|
st.classifier = classifier;
|
||||||
@@ -289,53 +201,18 @@ impl DebUi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the status bar message directly (for in-process work such as
|
|
||||||
/// tarball extraction that has no subprocess output)
|
|
||||||
pub fn progress_message(&self, msg: &str) {
|
|
||||||
if self.active() {
|
|
||||||
self.shared.top.set_message(msg.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drive the determinate progress bar directly (e.g. artifact retrieval)
|
|
||||||
pub fn count_progress(&self, label: &str, pos: usize, total: usize) {
|
|
||||||
if !self.active() || total == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
apply_progress(
|
|
||||||
&self.shared.top,
|
|
||||||
&mut self.shared.state.lock().unwrap(),
|
|
||||||
pos as u64,
|
|
||||||
total as u64,
|
|
||||||
);
|
|
||||||
self.shared.top.set_message(label.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the widget is enabled and still drawn
|
/// Whether the widget is enabled and still drawn
|
||||||
fn active(&self) -> bool {
|
fn active(&self) -> bool {
|
||||||
self.shared.enabled && !self.shared.suspended.load(Ordering::SeqCst)
|
self.shared.enabled && !self.shared.suspended.load(Ordering::SeqCst)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the widget renders at all (false on non-TTY stdout); callers
|
/// Release the widget from the terminal (e.g. before printing
|
||||||
/// use this to fall back to plain-line summaries
|
/// passthrough diagnostics or letting child cleanup commands write to
|
||||||
pub fn is_enabled(&self) -> bool {
|
/// the terminal); idempotent
|
||||||
self.shared.enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Obtain a sink feeding this view; pass it to `ContextCommand::capture`
|
|
||||||
pub fn sink(self: &Arc<Self>) -> Arc<dyn LineSink> {
|
|
||||||
Arc::new(Sink {
|
|
||||||
shared: self.shared.clone(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove the widget from the terminal (e.g. before printing passthrough
|
|
||||||
/// diagnostics or letting child cleanup commands write to the terminal);
|
|
||||||
/// idempotent
|
|
||||||
///
|
///
|
||||||
/// Steady ticks are disabled first: otherwise a tick can redraw a frame
|
/// Steady ticks are disabled first: otherwise a tick can redraw a frame
|
||||||
/// right after the clear, leaving stale copies of the widget on screen.
|
/// right after the clear, leaving stale copies of the widget on screen.
|
||||||
pub fn suspend(&self) {
|
fn suspend(&self) {
|
||||||
if !self.shared.enabled {
|
if !self.shared.enabled {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -348,21 +225,21 @@ impl DebUi {
|
|||||||
self.shared.pane.finish_and_clear();
|
self.shared.pane.finish_and_clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear the widget and print a success summary with the artifacts,
|
/// Success outcome body: clear the widget and print the artifacts,
|
||||||
/// rendered relative to the working directory when possible
|
/// rendered relative to the working directory when possible
|
||||||
pub fn finish_success(&self, artifacts: &[PathBuf], elapsed: Duration) {
|
fn success_summary(&self, artifacts: &[PathBuf], elapsed: Duration) {
|
||||||
self.suspend();
|
self.suspend();
|
||||||
if self.shared.enabled && !artifacts.is_empty() {
|
if self.shared.enabled && !artifacts.is_empty() {
|
||||||
println!("Built in {}s:", elapsed.as_secs());
|
println!("Built in {}s:", elapsed.as_secs());
|
||||||
for artifact in artifacts {
|
for artifact in artifacts {
|
||||||
println!(" {}", crate::ui::display_path(artifact));
|
println!(" {}", crate::report::display_path(artifact));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear the widget and print a failure summary (recent captured errors
|
/// Failure outcome body: clear the widget and print a summary (recent
|
||||||
/// and the path to the full log)
|
/// captured errors and the path to the full log)
|
||||||
pub fn finish_failure(&self) {
|
fn failure_summary(&self) {
|
||||||
self.suspend();
|
self.suspend();
|
||||||
|
|
||||||
let st = self.shared.state.lock().unwrap();
|
let st = self.shared.state.lock().unwrap();
|
||||||
@@ -385,15 +262,70 @@ impl DebUi {
|
|||||||
eprintln!("Full log: {}", log_path.display());
|
eprintln!("Full log: {}", log_path.display());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Time elapsed since the view was created
|
/// [`crate::report::BuildView`] port: forwards build events to the live
|
||||||
pub fn elapsed(&self) -> Duration {
|
/// widget, so core flows drive the view without knowing it is a terminal
|
||||||
self.shared.started.elapsed()
|
/// widget.
|
||||||
|
impl crate::report::BuildView for DebUi {
|
||||||
|
fn target(&self, target: BuildTarget<'_>) {
|
||||||
|
if self.shared.enabled {
|
||||||
|
self.shared.top.set_prefix(target.display.clone());
|
||||||
|
}
|
||||||
|
if target.tee_log {
|
||||||
|
let kind = if target.source_only { "build" } else { "deb" };
|
||||||
|
self.open_log(
|
||||||
|
kind,
|
||||||
|
target.package,
|
||||||
|
target.version,
|
||||||
|
&format!("for {}", target.target),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Path of the full build log file
|
fn phase(&self, name: &str, classifier: Box<dyn Classifier>) {
|
||||||
pub fn log_path(&self) -> PathBuf {
|
self.phase_custom(name, classifier);
|
||||||
self.shared.log_path.lock().unwrap().clone()
|
}
|
||||||
|
|
||||||
|
fn message(&self, text: &str) {
|
||||||
|
if self.active() {
|
||||||
|
self.shared.top.set_message(text.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn progress(&self, label: &str, pos: usize, total: usize) {
|
||||||
|
if !self.active() || total == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apply_progress(
|
||||||
|
&self.shared.top,
|
||||||
|
&mut self.shared.state.lock().unwrap(),
|
||||||
|
pos as u64,
|
||||||
|
total as u64,
|
||||||
|
);
|
||||||
|
self.shared.top.set_message(label.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sink(&self) -> Option<Arc<dyn LineSink>> {
|
||||||
|
Some(Arc::new(Sink {
|
||||||
|
shared: self.shared.clone(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_success(&self, artifacts: &[PathBuf]) {
|
||||||
|
self.success_summary(artifacts, self.shared.started.elapsed());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_failure(&self) {
|
||||||
|
self.failure_summary();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn suspend(&self) {
|
||||||
|
DebUi::suspend(self);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_enabled(&self) -> bool {
|
||||||
|
self.shared.enabled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+50
-4
@@ -12,7 +12,7 @@ use crossterm::{
|
|||||||
style::{self, Color, Print, SetForegroundColor},
|
style::{self, Color, Print, SetForegroundColor},
|
||||||
terminal,
|
terminal,
|
||||||
};
|
};
|
||||||
use std::io::{self, Write};
|
use std::io::{self, IsTerminal, Write};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
/// Why a prompt could not run interactively
|
/// Why a prompt could not run interactively
|
||||||
@@ -32,9 +32,8 @@ impl std::fmt::Display for PromptError {
|
|||||||
|
|
||||||
impl std::error::Error for PromptError {}
|
impl std::error::Error for PromptError {}
|
||||||
|
|
||||||
/// Answer validator of [`text`]: accepts the answer, or explains why it is
|
/// Answer validator of [`text`] (re-exported from [`crate::report`])
|
||||||
/// rejected
|
pub use crate::report::Validator;
|
||||||
pub type Validator = dyn Fn(&str) -> Result<(), String>;
|
|
||||||
|
|
||||||
/// What a prompt's event loop asks its drawing helper to render; keeping all
|
/// What a prompt's event loop asks its drawing helper to render; keeping all
|
||||||
/// terminal access behind this callback makes the loops pure logic that unit
|
/// terminal access behind this callback makes the loops pure logic that unit
|
||||||
@@ -127,6 +126,53 @@ pub fn confirm(label: &str, default: bool) -> Result<bool, Box<dyn std::error::E
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`crate::report::Prompter`] answered by the interactive terminal: each
|
||||||
|
/// question runs the matching raw-mode prompt. Without an interactive
|
||||||
|
/// terminal (CI, piped input) [`Prompter::interactive`] is false — flows
|
||||||
|
/// then take their headless path instead of asking — and a cancel (Ctrl+C)
|
||||||
|
/// propagates as `Err` so flows can abort.
|
||||||
|
pub struct TerminalPrompter;
|
||||||
|
|
||||||
|
impl crate::report::Prompter for TerminalPrompter {
|
||||||
|
fn interactive(&self) -> bool {
|
||||||
|
std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn confirm(&self, question: &str, default: bool) -> Result<bool, Box<dyn std::error::Error>> {
|
||||||
|
confirm(question, default)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select(
|
||||||
|
&self,
|
||||||
|
label: &str,
|
||||||
|
options: &[String],
|
||||||
|
default: &str,
|
||||||
|
) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
|
select(label, options, default)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text(
|
||||||
|
&self,
|
||||||
|
label: &str,
|
||||||
|
default: &str,
|
||||||
|
validate: Option<&Validator>,
|
||||||
|
) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
|
text(label, default, validate)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accept_host_key(&self, host: &str, key_type: &str, fingerprint: &str) -> bool {
|
||||||
|
// The banner is plain output: the confirmation prompt itself must
|
||||||
|
// stay a single line for its redraw logic
|
||||||
|
println!("The authenticity of host '{host}' can't be established.");
|
||||||
|
println!("{key_type} key fingerprint is {fingerprint}.");
|
||||||
|
confirm("Accept and store this host key?", false).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn present(&self, text: &str) {
|
||||||
|
println!("{text}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Run `prompt` with the terminal in raw mode, always restoring it
|
/// Run `prompt` with the terminal in raw mode, always restoring it
|
||||||
/// afterwards. Fails with [`PromptError::NotATty`] when raw mode cannot be
|
/// afterwards. Fails with [`PromptError::NotATty`] when raw mode cannot be
|
||||||
/// enabled.
|
/// enabled.
|
||||||
|
|||||||
Reference in New Issue
Block a user