v0.2.2 : suggestions d'installation selon l'OS (scoop/winget/apt/dnf/pacman/apk/brew) + README remanie
This commit is contained in:
@@ -12,6 +12,10 @@ pub fn run(app: &App, fix: bool) -> Result<i32> {
|
||||
for r in &repaired {
|
||||
app.log.success(r);
|
||||
}
|
||||
let runner = SystemRunner::new(false, app.cli.verbose, &app.log);
|
||||
for r in doctor::install_missing_tools(app, &runner)? {
|
||||
app.log.success(r);
|
||||
}
|
||||
app.log.info("run 'am doctor' again to verify");
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
+52
-18
@@ -111,34 +111,60 @@ pub fn check_agent(agent: &AgentDef, runner: &dyn Runner) -> Vec<DepCheck> {
|
||||
}
|
||||
|
||||
/// Fail with an actionable message when dependencies are missing.
|
||||
/// With auto_install_deps enabled, dependencies that define an install_cmd
|
||||
/// are offered for automatic installation first.
|
||||
///
|
||||
/// With auto_install_deps enabled (the default), missing tools are offered
|
||||
/// for installation right away: an explicit install_cmd from the config
|
||||
/// first, otherwise the command detected for the current OS
|
||||
/// (scoop/winget on Windows, apt/dnf/pacman/apk on Linux, brew on macOS).
|
||||
pub fn ensure_deps(app: &App, agent: &AgentDef, runner: &dyn Runner) -> Result<()> {
|
||||
let mut checks = check_agent(agent, runner);
|
||||
if checks.iter().all(|c| c.ok) {
|
||||
return Ok(());
|
||||
}
|
||||
let os = crate::toolchain::OsInfo::detect();
|
||||
if app.config.settings.auto_install_deps() && !app.dry_run() {
|
||||
for dep in &agent.dependencies {
|
||||
if dep.install_cmd.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if check_dep(runner, dep).ok {
|
||||
continue;
|
||||
}
|
||||
let cmd_line = dep.install_cmd.join(" ");
|
||||
app.log.warn(&format!("dependency '{}' is missing", dep.name));
|
||||
let proceed = app
|
||||
.confirm(&format!("Run '{cmd_line}' to install it?"))
|
||||
.unwrap_or(false);
|
||||
if proceed {
|
||||
let prog = dep.install_cmd[0].clone();
|
||||
let args: Vec<String> = dep.install_cmd[1..].to_vec();
|
||||
let empty = BTreeMap::new();
|
||||
let out = runner.run(&prog, &args, &empty, None, false)?;
|
||||
if out.code != 0 {
|
||||
app.log
|
||||
.warn(&format!("automatic installation of '{}' failed", dep.name));
|
||||
// 1) Explicit install command from the configuration.
|
||||
if !dep.install_cmd.is_empty() {
|
||||
let cmd_line = dep.install_cmd.join(" ");
|
||||
if app.confirm(&format!("Run '{cmd_line}' to install it?"))? {
|
||||
let prog = dep.install_cmd[0].clone();
|
||||
let args: Vec<String> = dep.install_cmd[1..].to_vec();
|
||||
let empty = BTreeMap::new();
|
||||
let out = runner.run(&prog, &args, &empty, None, false)?;
|
||||
if out.code != 0 {
|
||||
app.log.warn(&format!(
|
||||
"automatic installation of '{}' failed (exit code {})",
|
||||
dep.name, out.code
|
||||
));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// 2) Command detected for the current OS.
|
||||
let cmds = crate::toolchain::install_commands(&dep.name, &os);
|
||||
if let Some(cmd) = cmds.first() {
|
||||
let proceed = app
|
||||
.confirm(&format!("Run the detected installer for {}? -> {cmd}", os.name))?;
|
||||
if proceed {
|
||||
if let Ok(tokens) = shell_words::split(cmd) {
|
||||
if !tokens.is_empty() {
|
||||
let prog = tokens[0].clone();
|
||||
let args: Vec<String> = tokens[1..].to_vec();
|
||||
let empty = BTreeMap::new();
|
||||
let out = runner.run(&prog, &args, &empty, None, false)?;
|
||||
if out.code != 0 {
|
||||
app.log.warn(&format!(
|
||||
"installation of '{}' failed (exit code {})",
|
||||
dep.name, out.code
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,7 +179,15 @@ pub fn ensure_deps(app: &App, agent: &AgentDef, runner: &dyn Runner) -> Result<(
|
||||
if let Some(min) = &c.min {
|
||||
line.push_str(&format!(" (minimum: {min})"));
|
||||
}
|
||||
if let Some(dep) = agent.dependencies.iter().find(|d| d.name == c.name) {
|
||||
let cmds = crate::toolchain::install_commands(&c.name, &os);
|
||||
if !cmds.is_empty() {
|
||||
line.push_str(&format!(
|
||||
"
|
||||
on {}: {}",
|
||||
os.name,
|
||||
cmds.join(" | ")
|
||||
));
|
||||
} else if let Some(dep) = agent.dependencies.iter().find(|d| d.name == c.name) {
|
||||
if let Some(hint) = &dep.install_hint {
|
||||
line.push_str(&format!(" — install via {hint}"));
|
||||
}
|
||||
|
||||
+49
-6
@@ -63,6 +63,7 @@ pub fn run(app: &App, runner: &dyn Runner) -> DoctorReport {
|
||||
("uv", false),
|
||||
("docker", false),
|
||||
];
|
||||
let os = crate::toolchain::OsInfo::detect();
|
||||
for (tool, required) in tools {
|
||||
match runner.which(tool) {
|
||||
Some(path) => {
|
||||
@@ -84,14 +85,20 @@ pub fn run(app: &App, runner: &dyn Runner) -> DoctorReport {
|
||||
});
|
||||
}
|
||||
None => {
|
||||
let (status, detail) = if *required {
|
||||
(
|
||||
Status::Error,
|
||||
"required but not found on PATH".to_string(),
|
||||
)
|
||||
let mut detail = if *required {
|
||||
"required but not found on PATH".to_string()
|
||||
} else {
|
||||
(Status::Ok, "not installed".to_string())
|
||||
"not installed".to_string()
|
||||
};
|
||||
let cmds = crate::toolchain::install_commands(tool, &os);
|
||||
if !cmds.is_empty() {
|
||||
detail.push_str(&format!(
|
||||
" — on {}: {}",
|
||||
os.name,
|
||||
cmds.join(" | ")
|
||||
));
|
||||
}
|
||||
let status = if *required { Status::Error } else { Status::Ok };
|
||||
checks.push(Check {
|
||||
name: tool.to_string(),
|
||||
status,
|
||||
@@ -199,6 +206,42 @@ fn is_on_path(dir: &std::path::Path) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Offer to install the missing optional tools (node, python, ...) with
|
||||
/// the command detected for the current OS. Returns what was repaired.
|
||||
pub fn install_missing_tools(app: &App, runner: &dyn Runner) -> Result<Vec<String>> {
|
||||
let mut installed = Vec::new();
|
||||
let os = crate::toolchain::OsInfo::detect();
|
||||
for tool in ["node", "python", "cargo", "go", "bun", "uv", "docker"] {
|
||||
if runner.which(tool).is_some() {
|
||||
continue;
|
||||
}
|
||||
let cmds = crate::toolchain::install_commands(tool, &os);
|
||||
let Some(cmd) = cmds.first() else {
|
||||
continue;
|
||||
};
|
||||
if !app.confirm(&format!(
|
||||
"'{tool}' is missing — run the detected installer for {}? -> {cmd}",
|
||||
os.name
|
||||
))? {
|
||||
continue;
|
||||
}
|
||||
if let Ok(tokens) = shell_words::split(cmd) {
|
||||
if !tokens.is_empty() {
|
||||
let prog = tokens[0].clone();
|
||||
let args: Vec<String> = tokens[1..].to_vec();
|
||||
let empty = BTreeMap::new();
|
||||
let out = runner.run(&prog, &args, &empty, None, false)?;
|
||||
installed.push(if out.code == 0 {
|
||||
format!("installed '{tool}' ({cmd})")
|
||||
} else {
|
||||
format!("failed to install '{tool}' (exit code {})", out.code)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(installed)
|
||||
}
|
||||
|
||||
/// Repair what can be repaired: create directories, fix a corrupt state file.
|
||||
pub fn fix(app: &App) -> Result<Vec<String>> {
|
||||
let mut repaired = Vec::new();
|
||||
|
||||
+17
-4
@@ -136,11 +136,24 @@ pub fn sh_inherit(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Require a tool on the PATH, with an actionable error message.
|
||||
/// Require a tool on the PATH, with an actionable error message that
|
||||
/// includes the install command detected for the current OS.
|
||||
pub fn require_tool(runner: &dyn Runner, tool: &str, hint: &str) -> Result<PathBuf> {
|
||||
runner
|
||||
.which(tool)
|
||||
.ok_or_else(|| anyhow!("'{tool}' not found on PATH — install it first ({hint})"))
|
||||
if let Some(p) = runner.which(tool) {
|
||||
return Ok(p);
|
||||
}
|
||||
let os = crate::toolchain::OsInfo::detect();
|
||||
let mut msg = format!("'{tool}' not found on PATH — install it first ({hint})");
|
||||
let cmds = crate::toolchain::install_commands(tool, &os);
|
||||
if !cmds.is_empty() {
|
||||
msg.push_str(&format!(
|
||||
"
|
||||
on {}: {}",
|
||||
os.name,
|
||||
cmds.join(" | ")
|
||||
));
|
||||
}
|
||||
Err(anyhow!(msg))
|
||||
}
|
||||
|
||||
/// Detect a binary's version via --version, then -V.
|
||||
|
||||
@@ -26,6 +26,7 @@ pub mod process;
|
||||
pub mod repl;
|
||||
pub mod runner;
|
||||
pub mod state;
|
||||
pub mod toolchain;
|
||||
pub mod version;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
//! OS detection and concrete installation commands for missing toolchains.
|
||||
//!
|
||||
//! When an agent requires a tool (node, python, go, ...) that is missing,
|
||||
//! agent-manager detects the operating system — and the package manager
|
||||
//! already present (scoop/winget on Windows, apt/dnf/pacman/apk on Linux,
|
||||
//! brew on macOS) — and proposes the exact command to install it.
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Family {
|
||||
Windows,
|
||||
Macos,
|
||||
Linux,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OsInfo {
|
||||
pub name: String,
|
||||
pub family: Family,
|
||||
/// Package manager found on Linux: apt, dnf, pacman, apk or zypper.
|
||||
pub linux_pkg: Option<String>,
|
||||
pub has_scoop: bool,
|
||||
pub has_winget: bool,
|
||||
}
|
||||
|
||||
impl Default for OsInfo {
|
||||
fn default() -> Self {
|
||||
Self::detect()
|
||||
}
|
||||
}
|
||||
|
||||
impl OsInfo {
|
||||
/// Detect the current operating system and available package managers.
|
||||
pub fn detect() -> Self {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let has_scoop = which::which("scoop").is_ok();
|
||||
let has_winget = which::which("winget").is_ok();
|
||||
return Self {
|
||||
name: "Windows".to_string(),
|
||||
family: Family::Windows,
|
||||
linux_pkg: None,
|
||||
has_scoop,
|
||||
has_winget,
|
||||
};
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
return Self {
|
||||
name: "macOS".to_string(),
|
||||
family: Family::Macos,
|
||||
linux_pkg: None,
|
||||
has_scoop: false,
|
||||
has_winget: false,
|
||||
};
|
||||
}
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
{
|
||||
let id = linux_distro_id();
|
||||
let (name, pkg) = linux_pkg_for_id(&id);
|
||||
return Self {
|
||||
name: name.to_string(),
|
||||
family: Family::Linux,
|
||||
linux_pkg: pkg.map(|s| s.to_string()),
|
||||
has_scoop: false,
|
||||
has_winget: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
fn linux_distro_id() -> String {
|
||||
let Ok(text) = std::fs::read_to_string("/etc/os-release") else {
|
||||
return String::new();
|
||||
};
|
||||
for line in text.lines() {
|
||||
if let Some(v) = line.strip_prefix("ID=") {
|
||||
return v.trim().trim_matches('"').to_string();
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Map a distro id (from /etc/os-release) to (display name, package manager).
|
||||
pub fn linux_pkg_for_id(id: &str) -> (&'static str, Option<&'static str>) {
|
||||
match id {
|
||||
"debian" | "ubuntu" | "linuxmint" | "pop" | "raspbian" | "elementary" => {
|
||||
("Debian/Ubuntu", Some("apt"))
|
||||
}
|
||||
"fedora" | "rhel" | "centos" | "rocky" | "almalinux" | "ol" => {
|
||||
("Fedora/RHEL", Some("dnf"))
|
||||
}
|
||||
"arch" | "manjaro" | "endeavouros" | "artix" => ("Arch Linux", Some("pacman")),
|
||||
"alpine" => ("Alpine", Some("apk")),
|
||||
"opensuse-tumbleweed" | "opensuse-leap" | "suse" => ("openSUSE", Some("zypper")),
|
||||
_ => ("Linux", None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Concrete install commands for a tool on the detected OS, best option
|
||||
/// first (scoop before winget when both exist, etc.).
|
||||
pub fn install_commands(tool: &str, os: &OsInfo) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
match os.family {
|
||||
Family::Windows => {
|
||||
if os.has_scoop {
|
||||
if let Some(c) = scoop_command(tool) {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
if os.has_winget || out.is_empty() {
|
||||
if let Some(c) = winget_command(tool) {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
Family::Macos => {
|
||||
if let Some(c) = brew_command(tool) {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
Family::Linux => {
|
||||
if let Some(pkg) = os.linux_pkg.as_deref() {
|
||||
if let Some(c) = linux_command(tool, pkg) {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn scoop_command(tool: &str) -> Option<String> {
|
||||
let bucket = match tool {
|
||||
"git" => "git",
|
||||
"node" | "npm" => "nodejs-lts",
|
||||
"python" | "python3" | "pip" => "python",
|
||||
"cargo" | "rust" => "rustup",
|
||||
"go" => "go",
|
||||
"bun" => "bun",
|
||||
"uv" => "uv",
|
||||
"docker" => "docker",
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!("scoop install {bucket}"))
|
||||
}
|
||||
|
||||
fn winget_command(tool: &str) -> Option<String> {
|
||||
let package = match tool {
|
||||
"git" => "Git.Git",
|
||||
"node" | "npm" => "OpenJS.NodeJS.LTS",
|
||||
"python" | "python3" | "pip" => "Python.Python.3.12",
|
||||
"cargo" | "rust" => "Rustlang.Rustup",
|
||||
"go" => "GoLang.Go",
|
||||
"bun" => "Oven-sh.Bun",
|
||||
"uv" => "astral-sh.uv",
|
||||
"docker" => "Docker.DockerDesktop",
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!(
|
||||
"winget install --accept-source-agreements --accept-package-agreements {package}"
|
||||
))
|
||||
}
|
||||
|
||||
fn brew_command(tool: &str) -> Option<String> {
|
||||
Some(
|
||||
match tool {
|
||||
"git" => "brew install git".to_string(),
|
||||
"curl" => "brew install curl".to_string(),
|
||||
"node" | "npm" => "brew install node".to_string(),
|
||||
"python" | "python3" | "pip" => "brew install python".to_string(),
|
||||
"cargo" | "rust" => "brew install rustup".to_string(),
|
||||
"go" => "brew install go".to_string(),
|
||||
"bun" => "brew install oven-sh/bun/bun".to_string(),
|
||||
"uv" => "brew install uv".to_string(),
|
||||
"docker" => "brew install --cask docker".to_string(),
|
||||
_ => return None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn linux_command(tool: &str, pkg: &str) -> Option<String> {
|
||||
Some(
|
||||
match (tool, pkg) {
|
||||
("git", "apt") => "sudo apt install -y git",
|
||||
("git", "dnf") => "sudo dnf install -y git",
|
||||
("git", "pacman") => "sudo pacman -S --noconfirm git",
|
||||
("git", "apk") => "sudo apk add git",
|
||||
("git", "zypper") => "sudo zypper install -y git",
|
||||
("curl", "apt") => "sudo apt install -y curl",
|
||||
("curl", "dnf") => "sudo dnf install -y curl",
|
||||
("curl", "pacman") => "sudo pacman -S --noconfirm curl",
|
||||
("curl", "apk") => "sudo apk add curl",
|
||||
("curl", "zypper") => "sudo zypper install -y curl",
|
||||
("node" | "npm", "apt") => "sudo apt install -y nodejs npm",
|
||||
("node" | "npm", "dnf") => "sudo dnf install -y nodejs npm",
|
||||
("node" | "npm", "pacman") => "sudo pacman -S --noconfirm nodejs npm",
|
||||
("node" | "npm", "apk") => "sudo apk add nodejs npm",
|
||||
("node" | "npm", "zypper") => "sudo zypper install -y nodejs npm",
|
||||
("python" | "python3", "apt") => "sudo apt install -y python3 python3-venv python3-pip",
|
||||
("python" | "python3", "dnf") => "sudo dnf install -y python3 python3-pip",
|
||||
("python" | "python3", "pacman") => "sudo pacman -S --noconfirm python python-pip",
|
||||
("python" | "python3", "apk") => "sudo apk add python3 py3-pip",
|
||||
("python" | "python3", "zypper") => "sudo zypper install -y python3 python3-pip",
|
||||
("pip", "apt") => "sudo apt install -y python3-pip",
|
||||
("pip", "dnf") => "sudo dnf install -y python3-pip",
|
||||
("pip", "pacman") => "sudo pacman -S --noconfirm python-pip",
|
||||
("pip", "apk") => "sudo apk add py3-pip",
|
||||
("pip", "zypper") => "sudo zypper install -y python3-pip",
|
||||
("cargo" | "rust", "apt") => {
|
||||
"curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh"
|
||||
}
|
||||
("cargo" | "rust", "dnf") => "sudo dnf install -y rust cargo",
|
||||
("cargo" | "rust", "pacman") => "sudo pacman -S --noconfirm rust",
|
||||
("cargo" | "rust", "apk") => "sudo apk add rust cargo",
|
||||
("cargo" | "rust", "zypper") => "sudo zypper install -y rust cargo",
|
||||
("go", "apt") => "sudo apt install -y golang-go",
|
||||
("go", "dnf") => "sudo dnf install -y golang",
|
||||
("go", "pacman") => "sudo pacman -S --noconfirm go",
|
||||
("go", "apk") => "sudo apk add go",
|
||||
("go", "zypper") => "sudo zypper install -y go",
|
||||
("bun", "apt") => "curl -fsSL https://bun.sh/install | bash",
|
||||
("bun", "dnf") => "curl -fsSL https://bun.sh/install | bash",
|
||||
("bun", "pacman") => "curl -fsSL https://bun.sh/install | bash",
|
||||
("bun", "apk") => "sudo apk add bun",
|
||||
("bun", "zypper") => "curl -fsSL https://bun.sh/install | bash",
|
||||
("uv", "apt") => "curl -LsSf https://astral.sh/uv/install.sh | sh",
|
||||
("uv", "dnf") => "sudo dnf install -y uv",
|
||||
("uv", "pacman") => "sudo pacman -S --noconfirm uv",
|
||||
("uv", "apk") => "sudo apk add uv",
|
||||
("uv", "zypper") => "sudo zypper install -y uv",
|
||||
("docker", "apt") => "sudo apt install -y docker.io",
|
||||
("docker", "dnf") => "sudo dnf install -y docker",
|
||||
("docker", "pacman") => "sudo pacman -S --noconfirm docker",
|
||||
("docker", "apk") => "sudo apk add docker",
|
||||
("docker", "zypper") => "sudo zypper install -y docker",
|
||||
_ => return None,
|
||||
}
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn distro_mapping() {
|
||||
assert_eq!(linux_pkg_for_id("debian").1, Some("apt"));
|
||||
assert_eq!(linux_pkg_for_id("ubuntu").1, Some("apt"));
|
||||
assert_eq!(linux_pkg_for_id("fedora").1, Some("dnf"));
|
||||
assert_eq!(linux_pkg_for_id("arch").1, Some("pacman"));
|
||||
assert_eq!(linux_pkg_for_id("alpine").1, Some("apk"));
|
||||
assert_eq!(linux_pkg_for_id("opensuse-tumbleweed").1, Some("zypper"));
|
||||
assert_eq!(linux_pkg_for_id("").1, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_commands() {
|
||||
assert_eq!(scoop_command("node").as_deref(), Some("scoop install nodejs-lts"));
|
||||
assert_eq!(scoop_command("uv").as_deref(), Some("scoop install uv"));
|
||||
assert!(winget_command("git").unwrap().contains("Git.Git"));
|
||||
assert!(winget_command("python").unwrap().contains("--accept-source-agreements"));
|
||||
assert!(scoop_command("unknown-tool").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_commands() {
|
||||
assert!(linux_command("node", "apt").unwrap().contains("nodejs"));
|
||||
assert!(linux_command("cargo", "apt").unwrap().contains("rustup"));
|
||||
assert!(linux_command("go", "pacman").unwrap().contains("pacman -S"));
|
||||
assert!(linux_command("python", "apk").unwrap().contains("python3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_commands() {
|
||||
assert_eq!(brew_command("node").as_deref(), Some("brew install node"));
|
||||
assert!(brew_command("docker").unwrap().contains("--cask"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn os_aware_selection() {
|
||||
// Windows without scoop falls back to winget.
|
||||
let os = OsInfo {
|
||||
name: "Windows".into(),
|
||||
family: Family::Windows,
|
||||
linux_pkg: None,
|
||||
has_scoop: false,
|
||||
has_winget: true,
|
||||
};
|
||||
let cmds = install_commands("node", &os);
|
||||
assert!(!cmds.is_empty());
|
||||
assert!(cmds[0].contains("winget"));
|
||||
// Debian-like systems get apt commands.
|
||||
let deb = OsInfo {
|
||||
name: "Debian".into(),
|
||||
family: Family::Linux,
|
||||
linux_pkg: Some("apt".into()),
|
||||
has_scoop: false,
|
||||
has_winget: false,
|
||||
};
|
||||
let cmds = install_commands("go", &deb);
|
||||
assert!(!cmds.is_empty());
|
||||
assert!(cmds[0].contains("apt"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user