mod filtering;
mod injection;
mod rewrite;
mod staging;
use crate::commands::install::{self, FrozenMode, InstallOptions};
use aube_manifest::PackageJson;
use clap::Args;
use filtering::{dep_selection_for_args, keep_dep_for_args};
use miette::{Context, IntoDiagnostic, miette};
use staging::{StagedDeploy, stage_one};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Args)]
pub struct DeployArgs {
pub target: PathBuf,
#[arg(short = 'D', long, conflicts_with_all = ["prod", "no_prod"])]
pub dev: bool,
#[arg(long)]
pub no_optional: bool,
#[arg(short = 'P', long, visible_alias = "production")]
pub prod: bool,
#[arg(long, conflicts_with_all = ["prod", "dev"])]
pub no_prod: bool,
#[arg(long, conflicts_with = "prefer_offline")]
pub offline: bool,
#[arg(long, conflicts_with = "offline")]
pub prefer_offline: bool,
#[command(flatten)]
pub lockfile: crate::cli_args::LockfileArgs,
#[command(flatten)]
pub network: crate::cli_args::NetworkArgs,
#[command(flatten)]
pub virtual_store: crate::cli_args::VirtualStoreArgs,
}
pub async fn run(
args: DeployArgs,
filter: aube_workspace::selector::EffectiveFilter,
) -> miette::Result<()> {
args.network.install_overrides();
args.lockfile.install_overrides();
args.virtual_store.install_overrides();
if filter.is_empty() {
return Err(miette!(
"{}: --filter/-F is required to pick a workspace package",
aube_util::cmd("deploy")
));
}
let source_root = crate::dirs::cwd().wrap_err("failed to read current directory")?;
let files = crate::commands::FileSources::load(&source_root);
let raw_workspace = aube_manifest::workspace::load_raw(&source_root).unwrap_or_default();
let env = aube_settings::values::process_env();
let settings_ctx = files.ctx(&raw_workspace, env, &[]);
let deploy_all_files = aube_settings::resolved::deploy_all_files(&settings_ctx);
let catalogs = super::discover_catalogs(&source_root)?;
let workspace_pkgs = aube_workspace::find_workspace_packages(&source_root)
.map_err(|e| miette!("failed to discover workspace packages: {e}"))?;
if workspace_pkgs.is_empty() {
return Err(miette!(
"{}: no workspace packages found. \
`deploy` requires a workspace root (aube-workspace.yaml, pnpm-workspace.yaml, or package.json with a `workspaces` field) at {}",
aube_util::cmd("deploy"),
source_root.display()
));
}
let mut ws_index: BTreeMap<String, (PathBuf, Option<String>)> = BTreeMap::new();
for dir in &workspace_pkgs {
let Ok(m) = PackageJson::from_path(&dir.join("package.json")) else {
continue;
};
if let Some(n) = m.name {
ws_index.insert(n, (dir.clone(), m.version));
}
}
let selected =
aube_workspace::selector::select_workspace_packages(&source_root, &workspace_pkgs, &filter)
.map_err(|e| miette!("invalid --filter selector: {e}"))?;
let mut matches: Vec<(String, PathBuf)> = selected
.into_iter()
.filter_map(|pkg| pkg.name.map(|name| (name, pkg.dir)))
.collect();
matches.sort_by(|a, b| a.0.cmp(&b.0));
if matches.is_empty() {
let names: Vec<&str> = ws_index.keys().map(String::as_str).collect();
return Err(miette!(
"{}: --filter {:?} did not match any workspace package. Known: {}",
aube_util::cmd("deploy"),
filter,
names.join(", ")
));
}
let target_root = if args.target.is_absolute() {
args.target.clone()
} else {
source_root.join(&args.target)
};
let plan: Vec<(String, PathBuf, PathBuf)> = if matches.len() == 1 {
let (name, src) = matches.into_iter().next().unwrap();
vec![(name, src, target_root.clone())]
} else {
staging::ensure_target_writable(&target_root)?;
let mut used: BTreeMap<String, String> = BTreeMap::new();
let mut v = Vec::with_capacity(matches.len());
for (name, src) in matches {
let base = src
.file_name()
.and_then(|s| s.to_str())
.map(str::to_string)
.ok_or_else(|| {
miette!(
"{}: workspace package {} has no directory name",
aube_util::cmd("deploy"),
src.display()
)
})?;
if let Some(prev) = used.insert(base.clone(), name.clone()) {
return Err(miette!(
"{}: workspace packages {prev:?} and {name:?} both live in a directory named {base:?}; \
multi-package deploy uses the source basename as the target subdir, so these would collide",
aube_util::cmd("deploy")
));
}
v.push((name, src, target_root.join(&base)));
}
v
};
let mut staged: Vec<StagedDeploy> = Vec::with_capacity(plan.len());
for (_name, source_pkg_dir, target) in &plan {
staged.push(stage_one(
source_pkg_dir,
target,
&ws_index,
&catalogs,
&args,
deploy_all_files,
)?);
}
for (s, source_pkg_dir) in staged.iter().zip(plan.iter().map(|(_, src, _)| src)) {
let seeded = if s.bundled_local_refs {
tracing::debug!(
"deploy: bundled local refs into {}; skipping lockfile subset",
s.target.display()
);
false
} else {
seed_target_lockfile(&source_root, source_pkg_dir, &s.target, &args)?
};
super::retarget_cwd(&s.target)?;
let mode = if seeded {
FrozenMode::Prefer
} else {
FrozenMode::No
};
let network_mode = if args.offline {
aube_registry::NetworkMode::Offline
} else if args.prefer_offline {
aube_registry::NetworkMode::PreferOffline
} else {
aube_registry::NetworkMode::Online
};
let opts = InstallOptions {
control: install::InstallControl::default(),
project_dir: Some(s.target.clone()),
mode,
dep_selection: dep_selection_for_args(&args),
ignore_pnpmfile: false,
pnpmfile: None,
global_pnpmfile: None,
ignore_scripts: false,
dry_run: false,
lockfile_only: false,
merge_git_branch_lockfiles: false,
dangerously_allow_all_builds: false,
network_mode,
minimum_release_age_override: None,
strict_no_lockfile: false,
force: false,
cli_flags: vec![(
"dangerously-allow-all-builds".to_string(),
"false".to_string(),
)],
env_snapshot: aube_settings::values::capture_env(),
git_prepare_depth: 0,
inherited_build_policy: None,
build_policy_override: None,
workspace_filter: aube_workspace::selector::EffectiveFilter::default(),
skip_root_lifecycle: false,
osv_transitive_check: false,
};
install::run(opts).await?;
println!(
"deployed {}@{} to {}",
s.name,
s.version,
s.target.display()
);
}
Ok(())
}
pub(super) fn canonicalize(p: &Path) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
}
fn seed_target_lockfile(
source_root: &Path,
source_pkg_dir: &Path,
target: &Path,
args: &DeployArgs,
) -> miette::Result<bool> {
let Ok(source_manifest) = PackageJson::from_path(&source_root.join("package.json")) else {
tracing::debug!("deploy: workspace root package.json unreadable, skipping lockfile subset");
return Ok(false);
};
let (graph, kind) = match aube_lockfile::parse_lockfile_with_kind(source_root, &source_manifest)
{
Ok(pair) => pair,
Err(e) => {
tracing::debug!("deploy: no usable source lockfile ({e}); fresh install instead");
return Ok(false);
}
};
let importer_path = super::workspace_importer_path(source_root, source_pkg_dir)?;
let Some(mut subset) = graph.subset_to_importer(&importer_path, keep_dep_for_args(args)) else {
tracing::debug!(
"deploy: importer {importer_path:?} not in source lockfile; fresh install instead"
);
return Ok(false);
};
let has_local_root = subset.root_deps().iter().any(|d| {
subset
.get_package(&d.dep_path)
.and_then(|p| p.local_source.as_ref())
.is_some_and(|src| {
matches!(
src,
aube_lockfile::LocalSource::Link(_)
| aube_lockfile::LocalSource::Portal(_)
| aube_lockfile::LocalSource::Exec(_)
| aube_lockfile::LocalSource::Directory(_)
| aube_lockfile::LocalSource::Tarball(_)
)
})
});
if has_local_root {
tracing::debug!("deploy: source importer has link:/file: roots; fresh install instead");
return Ok(false);
}
subset.overrides.clear();
subset.ignored_optional_dependencies.clear();
subset.catalogs.clear();
let canonical_keys: std::collections::HashSet<String> =
subset.packages.values().map(|pkg| pkg.spec_key()).collect();
subset.times.retain(|key, _| canonical_keys.contains(key));
let target_manifest = PackageJson::from_path(&target.join("package.json"))
.map_err(miette::Report::new)
.wrap_err("deploy: failed to re-read rewritten target package.json")?;
aube_lockfile::write_lockfile_as(target, &subset, &target_manifest, kind)
.into_diagnostic()
.wrap_err("deploy: failed to write subset lockfile into target")?;
Ok(true)
}