feat: enhance install command to accept multiple package specifications and improve runtime environment handling

This commit is contained in:
2026-03-01 21:47:44 -06:00
parent 3080eea58a
commit 6cae1f636a
9 changed files with 524 additions and 345 deletions
+54 -4
View File
@@ -53,12 +53,22 @@ pub struct Cli {
pub enum Commands {
/// Build and install a package from a spec file
Install {
/// Path to package spec (.toml) or package archive (.tar.zst)
#[arg(value_name = "SPEC_OR_ARCHIVE")]
spec_or_archive: PathBuf,
/// One or more package names, spec paths (.toml), or package archives (.tar.zst)
#[arg(
value_name = "SPEC_OR_ARCHIVE",
num_args = 1..,
required_unless_present = "spec"
)]
spec_or_archive: Vec<PathBuf>,
/// Explicitly specify path to package spec (.toml file)
#[arg(short, long = "spec", visible_alias = "package", alias = "p")]
#[arg(
short,
long = "spec",
visible_alias = "package",
alias = "p",
conflicts_with = "spec_or_archive"
)]
spec: Option<PathBuf>,
},
/// Remove an installed package
@@ -220,3 +230,43 @@ pub enum RepoKindArg {
Source,
Binary,
}
#[cfg(test)]
mod tests {
use super::{Cli, Commands};
use clap::Parser;
use std::path::PathBuf;
#[test]
fn install_accepts_multiple_positional_targets() {
let cli = Cli::try_parse_from(["depot", "install", "base", "linux"]).unwrap();
match cli.command {
Commands::Install {
spec_or_archive,
spec,
} => {
assert!(spec.is_none());
assert_eq!(
spec_or_archive,
vec![PathBuf::from("base"), PathBuf::from("linux")]
);
}
_ => panic!("expected install command"),
}
}
#[test]
fn install_accepts_spec_flag_without_positional_target() {
let cli = Cli::try_parse_from(["depot", "install", "--spec", "pkg.toml"]).unwrap();
match cli.command {
Commands::Install {
spec_or_archive,
spec,
} => {
assert!(spec_or_archive.is_empty());
assert_eq!(spec, Some(PathBuf::from("pkg.toml")));
}
_ => panic!("expected install command"),
}
}
}
+165 -97
View File
@@ -1255,73 +1255,47 @@ fn execute_install_plan_with_child_commands(
Ok(())
}
pub fn run(cli: Cli) -> Result<()> {
ui::set_assume_yes(cli.yes);
match cli.command {
Commands::Install {
spec_or_archive,
spec,
} => {
if !crate::fakeroot::is_root() {
anyhow::bail!("The 'install' command must be run as root");
}
let mut spec_path = spec.unwrap_or(spec_or_archive);
// Load configuration early so we can use the configured repo clone dir
let config = config::Config::for_rootfs(&cli.rootfs);
if !cli.no_deps {
let request_path_like = spec_path.exists();
let is_archive_request = request_path_like
fn is_archive_install_request(spec_path: &Path) -> bool {
spec_path.exists()
&& spec_path
.to_string_lossy()
.to_ascii_lowercase()
.ends_with(".tar.zst");
if !is_archive_request {
let target = if request_path_like {
planner::InstallTarget::SpecPath(spec_path.clone())
} else {
planner::InstallTarget::PackageName(spec_path.to_string_lossy().to_string())
};
let local_sibling_root = spec_path
.parent()
.and_then(|p| p.parent())
.map(Path::to_path_buf);
let plan = planner::build_install_plan(
&config,
&cli.rootfs,
target,
planner::PlannerOptions {
assume_yes: cli.yes,
prefer_binary: config.repo_settings.prefer_binary,
local_sibling_root,
},
)?;
print_plan_summary(&plan);
execute_install_plan_with_child_commands(
&plan,
&cli.rootfs,
&config,
InstallPlanExecutionOptions {
no_flags: cli.no_flags,
cross_prefix: cli.cross_prefix.as_deref(),
clean: cli.clean,
dry_run: cli.dry_run,
confirm_installation: true,
},
)?;
if cli.clean {
clean_build_workspace(&config)?;
}
return Ok(());
}
}
.ends_with(".tar.zst")
}
let mut install_lock = locking::open_lock(&config)?;
let install_lock_path = locking::lock_path(&config);
let _install_lock_guard =
locking::try_write(&mut install_lock, &install_lock_path, "install")?;
fn shared_local_sibling_root(spec_paths: &[PathBuf]) -> Option<PathBuf> {
let mut roots = spec_paths.iter().filter_map(|path| {
path.parent()
.and_then(|p| p.parent())
.map(Path::to_path_buf)
});
let first = roots.next()?;
if roots.all(|path| path == first) {
Some(first)
} else {
None
}
}
#[derive(Clone, Copy)]
struct DirectInstallOptions<'a> {
rootfs: &'a Path,
no_deps: bool,
no_flags: bool,
cross_prefix: Option<&'a str>,
clean: bool,
dry_run: bool,
lib32_only: bool,
}
fn run_direct_install_request(
options: DirectInstallOptions<'_>,
config: &config::Config,
mut spec_path: PathBuf,
) -> Result<bool> {
let mut install_lock = locking::open_lock(config)?;
let install_lock_path = locking::lock_path(config);
let _install_lock_guard = locking::try_write(&mut install_lock, &install_lock_path, "install")?;
// Repo clone dir is available via `config.repo_clone_dir` and
// is passed explicitly to index builders below.
@@ -1342,14 +1316,13 @@ pub fn run(cli: Cli) -> Result<()> {
.iter()
.filter(|(_, repo)| repo.enabled && repo.supports_arch(host_arch))
.collect();
binary_repos
.sort_by(|a, b| a.1.priority.cmp(&b.1.priority).then_with(|| a.0.cmp(b.0)));
binary_repos.sort_by(|a, b| a.1.priority.cmp(&b.1.priority).then_with(|| a.0.cmp(b.0)));
for (repo_name, repo_cfg) in binary_repos {
match db::repo::find_binary_repo_package(
repo_name,
repo_cfg,
&cli.rootfs,
options.rootfs,
&config.package_cache_dir,
&name,
) {
@@ -1357,7 +1330,7 @@ pub fn run(cli: Cli) -> Result<()> {
let archive = db::repo::fetch_binary_package_archive(
repo_name,
repo_cfg,
&cli.rootfs,
options.rootfs,
&rec,
&config.package_cache_dir,
)?;
@@ -1398,11 +1371,11 @@ pub fn run(cli: Cli) -> Result<()> {
} else {
// Install from spec (normal build)
let mut pkg_spec = package::PackageSpec::from_file(&spec_path)?;
pkg_spec.apply_config(&config);
pkg_spec.apply_config(config);
(pkg_spec, None)
};
if cli.lib32_only && staging_dir.is_some() {
if options.lib32_only && staging_dir.is_some() {
anyhow::bail!("--lib32-only is only supported when installing from a package spec");
}
@@ -1411,9 +1384,9 @@ pub fn run(cli: Cli) -> Result<()> {
pkg_spec.package.name, pkg_spec.package.version, pkg_spec.package.revision
));
if cli.dry_run {
if options.dry_run {
ui::info("Dry run enabled, stopping before install/build work.");
return Ok(());
return Ok(false);
}
let install_targets = vec![format!(
@@ -1440,7 +1413,7 @@ pub fn run(cli: Cli) -> Result<()> {
}
// Check dependencies and prompt for auto-install if needed
if !cli.no_deps {
if !options.no_deps {
deps::print_dep_status(&pkg_spec, &db_path)?;
// Collect all missing dependencies (build + runtime)
@@ -1465,9 +1438,8 @@ pub fn run(cli: Cli) -> Result<()> {
ui::warn(format!("Missing dependencies: {}", missing.join(", ")));
if ui::prompt_package_action("dependency installation", &missing, true)? {
// Build package index for fast lookups
let pkg_index = index::PackageIndex::build_with_repo_dir(Some(
config.repo_clone_dir.clone(),
));
let pkg_index =
index::PackageIndex::build_with_repo_dir(Some(config.repo_clone_dir.clone()));
// Build new dep chain
let new_chain = if dep_chain.is_empty() {
@@ -1485,18 +1457,18 @@ pub fn run(cli: Cli) -> Result<()> {
ui::info(format!("Installing dependency: {}...", dep));
let mut cmd = std::process::Command::new(std::env::current_exe()?);
cmd.arg("-r").arg(&cli.rootfs);
cmd.arg("-r").arg(options.rootfs);
if cli.no_deps {
if options.no_deps {
cmd.arg("--no-deps");
}
if cli.no_flags {
if options.no_flags {
cmd.arg("--no-flags");
}
if let Some(ref p) = cli.cross_prefix {
if let Some(p) = options.cross_prefix {
cmd.arg("--cross-prefix").arg(p);
}
if cli.clean {
if options.clean {
cmd.arg("--clean");
}
@@ -1509,10 +1481,7 @@ pub fn run(cli: Cli) -> Result<()> {
anyhow::bail!("Failed to install dependency: {}", dep);
}
} else {
anyhow::bail!(
"Could not find package spec for dependency: {}",
dep
);
anyhow::bail!("Could not find package spec for dependency: {}", dep);
}
}
}
@@ -1523,10 +1492,9 @@ pub fn run(cli: Cli) -> Result<()> {
deps::require_runtime_deps(&pkg_spec, &db_path)?;
}
let cross_config = cli
let cross_config = options
.cross_prefix
.as_ref()
.map(|p| cross::CrossConfig::from_prefix(p))
.map(cross::CrossConfig::from_prefix)
.transpose()?;
let mut built_src_dir: Option<PathBuf> = None;
@@ -1543,13 +1511,13 @@ pub fn run(cli: Cli) -> Result<()> {
.join("destdir")
.join(&pkg_spec.package.name);
if !cli.lib32_only {
if !options.lib32_only {
builder::build(
&pkg_spec,
&src_dir,
&destdir,
cross_config.as_ref(),
!cli.no_flags,
!options.no_flags,
)?;
// 3.1 Copy license files into staged tree
@@ -1560,7 +1528,7 @@ pub fn run(cli: Cli) -> Result<()> {
destdir
};
if !cli.lib32_only {
if !options.lib32_only {
if staging_dir.is_none() {
// Source-build path: apply staging transforms (strip/compress/static cleanup).
staging::process(&destdir, &pkg_spec)?;
@@ -1570,7 +1538,7 @@ pub fn run(cli: Cli) -> Result<()> {
}
let installed =
install_package_outputs_to_rootfs(&pkg_spec, &destdir, &cli.rootfs, &config)?;
install_package_outputs_to_rootfs(&pkg_spec, &destdir, options.rootfs, config)?;
for pkg in installed {
ui::success(format!(
"Successfully installed {} v{}",
@@ -1584,22 +1552,122 @@ pub fn run(cli: Cli) -> Result<()> {
&& let Some((lib32_spec, lib32_destdir)) = build_lib32_companion_package(
&pkg_spec,
src_dir,
&config,
config,
cross_config.as_ref(),
!cli.no_flags,
cli.lib32_only,
!options.no_flags,
options.lib32_only,
)?
{
install_staged_to_rootfs(&lib32_spec, &lib32_destdir, &cli.rootfs, &config)?;
install_staged_to_rootfs(&lib32_spec, &lib32_destdir, options.rootfs, config)?;
ui::success(format!(
"Successfully installed {} v{}",
lib32_spec.package.name, lib32_spec.package.version
));
}
install::scripts::run_deferred_hooks_if_possible(&cli.rootfs)?;
Ok(true)
}
if cli.clean {
pub fn run(cli: Cli) -> Result<()> {
ui::set_assume_yes(cli.yes);
match cli.command {
Commands::Install {
spec_or_archive,
spec,
} => {
if !crate::fakeroot::is_root() {
anyhow::bail!("The 'install' command must be run as root");
}
let install_requests = match spec {
Some(spec_path) => vec![spec_path],
None => spec_or_archive,
};
// Load configuration early so we can use configured repos/paths.
let config = config::Config::for_rootfs(&cli.rootfs);
let mut planned_targets = Vec::new();
let mut planned_spec_paths = Vec::new();
let mut direct_requests = Vec::new();
if cli.no_deps {
direct_requests = install_requests;
} else {
for request in install_requests {
if is_archive_install_request(&request) {
direct_requests.push(request);
continue;
}
if request.exists() {
planned_spec_paths.push(request.clone());
planned_targets.push(planner::InstallTarget::SpecPath(request));
} else {
planned_targets.push(planner::InstallTarget::PackageName(
request.to_string_lossy().to_string(),
));
}
}
}
let mut ran_plan_mode = false;
if !planned_targets.is_empty() {
ran_plan_mode = true;
let planner_opts = planner::PlannerOptions {
assume_yes: cli.yes,
prefer_binary: config.repo_settings.prefer_binary,
local_sibling_root: shared_local_sibling_root(&planned_spec_paths),
};
let plan = if planned_targets.len() == 1 {
planner::build_install_plan(
&config,
&cli.rootfs,
planned_targets[0].clone(),
planner_opts,
)?
} else {
planner::build_install_plan_for_targets(
&config,
&cli.rootfs,
&planned_targets,
planner_opts,
)?
};
print_plan_summary(&plan);
execute_install_plan_with_child_commands(
&plan,
&cli.rootfs,
&config,
InstallPlanExecutionOptions {
no_flags: cli.no_flags,
cross_prefix: cli.cross_prefix.as_deref(),
clean: cli.clean,
dry_run: cli.dry_run,
confirm_installation: true,
},
)?;
}
let mut ran_direct_install = false;
for request in direct_requests {
ran_direct_install |= run_direct_install_request(
DirectInstallOptions {
rootfs: &cli.rootfs,
no_deps: cli.no_deps,
no_flags: cli.no_flags,
cross_prefix: cli.cross_prefix.as_deref(),
clean: cli.clean,
dry_run: cli.dry_run,
lib32_only: cli.lib32_only,
},
&config,
request,
)?;
}
if ran_direct_install {
install::scripts::run_deferred_hooks_if_possible(&cli.rootfs)?;
}
if cli.clean && (ran_plan_mode || ran_direct_install) {
clean_build_workspace(&config)?;
}
}
+2 -1
View File
@@ -429,7 +429,8 @@ fn run_hook_command(
.env("DEPOT_PACKAGE", ctx.package)
.env("DEPOT_ROOTFS", rootfs)
.env("DEPOT_HOOK_FILE", &hook.file_path)
.env("DEPOT_HOOK_NAME", &hook_name);
.env("DEPOT_HOOK_NAME", &hook_name)
.env("PATH", crate::runtime_env::safe_script_path());
let status = run_command_with_optional_stdin(&mut command, stdin_payload.as_deref())
.with_context(|| {
+27 -1
View File
@@ -467,6 +467,7 @@ fn run_script_with_rootfs_context(
.env("DEPOT_ROOTFS", &rootfs_env)
.env("DEPOT_ACTION", hook.action())
.env("DEPOT_PHASE", hook.phase())
.env("PATH", crate::runtime_env::safe_script_path())
.status()
.with_context(|| {
format!(
@@ -497,7 +498,8 @@ fn run_hook_script_in_chroot(
.env("DEPOT_PACKAGE", pkg_name)
.env("DEPOT_ROOTFS", "/")
.env("DEPOT_ACTION", hook.action())
.env("DEPOT_PHASE", hook.phase());
.env("DEPOT_PHASE", hook.phase())
.env("PATH", crate::runtime_env::safe_script_path());
if quiet {
cmd.stdout(Stdio::null()).stderr(Stdio::null());
}
@@ -1068,6 +1070,30 @@ mod tests {
);
}
#[test]
fn run_hook_if_present_uses_safe_script_path() {
let tmp = tempfile::tempdir().unwrap();
let scripts = tmp.path().join("scripts");
let rootfs = tmp.path().join("root");
std::fs::create_dir_all(&scripts).unwrap();
std::fs::create_dir_all(&rootfs).unwrap();
std::fs::write(
scripts.join("pre_install"),
"echo \"$PATH\" > \"$DEPOT_ROOTFS/path.out\"\n",
)
.unwrap();
let ran = run_hook_if_present(&scripts, Hook::PreInstall, &rootfs, "foo").unwrap();
assert!(ran);
assert_eq!(
std::fs::read_to_string(rootfs.join("path.out"))
.unwrap()
.trim_end(),
crate::runtime_env::safe_script_path()
);
}
#[test]
fn run_hook_if_present_accepts_compact_script_name() {
let tmp = tempfile::tempdir().unwrap();
+1
View File
@@ -15,6 +15,7 @@ mod install;
mod locking;
mod package;
mod planner;
mod runtime_env;
mod shell_helpers;
mod signing;
mod source;
+26 -5
View File
@@ -179,15 +179,27 @@ impl<'a> Resolver<'a> {
}
fn plan_for_install_target(mut self, target: InstallTarget) -> Result<ExecutionPlan> {
self.resolve_install_target(&target)?;
self.finish_plan()
}
fn plan_for_install_targets(mut self, targets: &[InstallTarget]) -> Result<ExecutionPlan> {
for target in targets {
self.resolve_install_target(target)?;
}
self.finish_plan()
}
fn resolve_install_target(&mut self, target: &InstallTarget) -> Result<()> {
match target {
InstallTarget::PackageName(name) => {
if deps::is_dep_satisfied_in_db(&name, &self.db_path)? {
if deps::is_dep_satisfied_in_db(name, &self.db_path)? {
if self.emitted_installed_roots.insert(name.clone()) {
self.add_installed_root_step(name, "requested package".to_string());
self.add_installed_root_step(name.clone(), "requested package".to_string());
}
} else {
let root =
self.resolve_dep_node(&name, None, "requested package".to_string())?;
self.resolve_dep_node(name, None, "requested package".to_string())?;
if let Some(root_idx) = root {
self.mark_requested_by(root_idx, "requested package".to_string());
}
@@ -195,11 +207,11 @@ impl<'a> Resolver<'a> {
}
InstallTarget::SpecPath(path) => {
let root_idx =
self.ensure_source_spec_node(&path, true, true, "requested spec".to_string())?;
self.ensure_source_spec_node(path, true, true, "requested spec".to_string())?;
self.mark_requested_by(root_idx, "requested spec".to_string());
}
}
self.finish_plan()
Ok(())
}
fn plan_for_deps(mut self, deps_to_install: &[String]) -> Result<ExecutionPlan> {
@@ -702,6 +714,15 @@ pub(crate) fn build_install_plan(
Resolver::new(config, rootfs, opts).plan_for_install_target(target)
}
pub(crate) fn build_install_plan_for_targets(
config: &Config,
rootfs: &Path,
targets: &[InstallTarget],
opts: PlannerOptions,
) -> Result<ExecutionPlan> {
Resolver::new(config, rootfs, opts).plan_for_install_targets(targets)
}
pub(crate) fn build_dependency_install_plan(
config: &Config,
rootfs: &Path,
+16
View File
@@ -0,0 +1,16 @@
//! Shared runtime environment defaults for script and hook execution.
use std::path::Path;
/// Deterministic command search path for shell scripts and hook commands.
pub const SAFE_SCRIPT_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
/// Return the deterministic command search path used for script execution.
pub fn safe_script_path() -> &'static str {
SAFE_SCRIPT_PATH
}
/// Prepend a helper binary directory before the deterministic script path.
pub fn prepend_helper_to_safe_path(helper_bin_dir: &Path) -> String {
format!("{}:{}", helper_bin_dir.display(), SAFE_SCRIPT_PATH)
}
+1 -7
View File
@@ -63,13 +63,7 @@ impl ShellHelpers {
})?;
}
let parent_path = std::env::var("PATH").unwrap_or_default();
let helper_path = bin_dir.to_string_lossy().into_owned();
let path_value = if parent_path.is_empty() {
helper_path
} else {
format!("{}:{}", helper_path, parent_path)
};
let path_value = crate::runtime_env::prepend_helper_to_safe_path(&bin_dir);
Ok(Self {
_tempdir: tempdir,
+2
View File
@@ -61,6 +61,7 @@ fn apply_patches(
// Apply with patch(1). Keep it simple: -p1 is the common case.
let status = Command::new("patch")
.current_dir(src_dir)
.env("PATH", crate::runtime_env::safe_script_path())
.arg("-p1")
.arg("-i")
.arg(&patch_path)
@@ -93,6 +94,7 @@ fn run_post_extract_commands(spec: &PackageSpec, source: &Source, src_dir: &Path
let status = Command::new("sh")
.current_dir(src_dir)
.env("DEPOT_SPECDIR", &spec.spec_dir)
.env("PATH", crate::runtime_env::safe_script_path())
.arg("-c")
.arg(&cmd)
.status()