use crate::commands::CatalogMap;
use crate::commands::install::{self, FrozenMode, InstallOptions};
use crate::commands::pack::collect_package_files;
use aube_manifest::PackageJson;
use clap::Args;
use miette::{Context, IntoDiagnostic, miette};
use std::collections::{BTreeMap, VecDeque};
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!(
"aube deploy: --filter/-F is required to pick a workspace package"
));
}
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!(
"aube deploy: no workspace packages found. \
`deploy` requires a workspace root (aube-workspace.yaml, pnpm-workspace.yaml, or package.json with a `workspaces` field) at {}",
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!(
"aube deploy: --filter {:?} did not match any workspace package. Known: {}",
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 {
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!(
"aube deploy: workspace package {} has no directory name",
src.display()
)
})?;
if let Some(prev) = used.insert(base.clone(), name.clone()) {
return Err(miette!(
"aube deploy: 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"
));
}
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 {
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,
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::new(),
env_snapshot: aube_settings::values::capture_env(),
git_prepare_depth: 0,
inherited_build_policy: None,
workspace_filter: aube_workspace::selector::EffectiveFilter::default(),
skip_root_lifecycle: false,
};
install::run(opts).await?;
println!(
"deployed {}@{} to {}",
s.name,
s.version,
s.target.display()
);
}
Ok(())
}
struct StagedDeploy {
name: String,
version: String,
target: PathBuf,
bundled_local_refs: bool,
}
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::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)
}
fn stage_one(
source_pkg_dir: &Path,
target: &Path,
ws_index: &BTreeMap<String, (PathBuf, Option<String>)>,
catalogs: &CatalogMap,
args: &DeployArgs,
deploy_all_files: bool,
) -> miette::Result<StagedDeploy> {
ensure_target_writable(target)?;
std::fs::create_dir_all(target)
.into_diagnostic()
.wrap_err_with(|| format!("failed to create {}", target.display()))?;
let manifest = super::load_manifest(&source_pkg_dir.join("package.json"))?;
let name = manifest
.name
.clone()
.ok_or_else(|| miette!("deploy: package.json has no `name` field"))?;
let version = manifest
.version
.clone()
.unwrap_or_else(|| "0.0.0".to_string());
let files = if deploy_all_files {
collect_all_files(source_pkg_dir, target)?
} else {
collect_package_files(source_pkg_dir, &manifest)?
};
for (src, rel) in &files {
let dst = target.join(rel);
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)
.into_diagnostic()
.wrap_err_with(|| format!("failed to create {}", parent.display()))?;
}
std::fs::copy(src, &dst)
.into_diagnostic()
.wrap_err_with(|| format!("failed to copy {} -> {}", src.display(), dst.display()))?;
}
let plan = plan_injections(source_pkg_dir, target, ws_index, args)?;
materialize_injections(&plan, ws_index, deploy_all_files)?;
let deployed_canonical = canonicalize(source_pkg_dir);
let root = DeployRoot {
deployed_canonical: &deployed_canonical,
target_root: target,
};
rewrite_local_refs(
&target.join("package.json"),
source_pkg_dir,
target,
ws_index,
catalogs,
&plan,
StripFields::for_args(args),
root,
)?;
let bundled_strip = StripFields::for_bundled_sibling(args);
for inj in plan.values() {
if inj.is_tarball {
continue;
}
rewrite_local_refs(
&inj.target_dir.join("package.json"),
&inj.source_dir,
&inj.target_dir,
ws_index,
catalogs,
&plan,
bundled_strip,
root,
)?;
}
Ok(StagedDeploy {
name,
version,
target: target.to_path_buf(),
bundled_local_refs: !plan.is_empty(),
})
}
#[derive(Debug, Clone)]
struct Injection {
source_dir: PathBuf,
is_tarball: bool,
target_dir: PathBuf,
tarball_filename: String,
}
type InjectionPlan = BTreeMap<PathBuf, Injection>;
fn plan_injections(
deployed_pkg_dir: &Path,
target_root: &Path,
ws_index: &BTreeMap<String, (PathBuf, Option<String>)>,
args: &DeployArgs,
) -> miette::Result<InjectionPlan> {
let injected_root = target_root.join(".aube-deploy-injected");
let mut plan: InjectionPlan = BTreeMap::new();
let mut used_ids: BTreeMap<String, u32> = BTreeMap::new();
let deployed_canonical = canonicalize(deployed_pkg_dir);
let mut queue: VecDeque<(PathBuf, StripFields)> = VecDeque::new();
queue.push_back((deployed_pkg_dir.to_path_buf(), StripFields::for_args(args)));
while let Some((pkg_dir, strip)) = queue.pop_front() {
let manifest_path = pkg_dir.join("package.json");
let manifest = super::load_manifest(&manifest_path)?;
for (dep_name, dep_spec) in iter_strippable_deps(&manifest, strip) {
if aube_util::pkg::is_workspace_spec(&dep_spec) {
let Some((sibling_dir, _)) = ws_index.get(&dep_name) else {
return Err(miette!(
"aube deploy: {} declares `{dep_name}: {dep_spec}` but no workspace package named {dep_name:?} was found",
manifest_path.display()
));
};
let canonical = canonicalize(sibling_dir);
if canonical == deployed_canonical {
continue;
}
if !plan.contains_key(&canonical) {
let id = unique_id(&dep_name, &mut used_ids);
plan.insert(
canonical.clone(),
Injection {
source_dir: canonical.clone(),
is_tarball: false,
target_dir: injected_root.join(&id),
tarball_filename: String::new(),
},
);
queue.push_back((canonical, StripFields::for_bundled_sibling(args)));
}
} else if let Some(local) = aube_lockfile::LocalSource::parse(&dep_spec, &pkg_dir) {
match local {
aube_lockfile::LocalSource::Directory(rel)
| aube_lockfile::LocalSource::Link(rel) => {
let abs = pkg_dir.join(&rel);
let canonical = canonicalize(&abs);
if canonical == deployed_canonical {
continue;
}
if !plan.contains_key(&canonical) {
let id_seed = canonical
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(&dep_name);
let id = unique_id(id_seed, &mut used_ids);
plan.insert(
canonical.clone(),
Injection {
source_dir: canonical.clone(),
is_tarball: false,
target_dir: injected_root.join(&id),
tarball_filename: String::new(),
},
);
queue.push_back((
canonical.clone(),
StripFields::for_bundled_sibling(args),
));
}
}
aube_lockfile::LocalSource::Tarball(rel) => {
let abs = pkg_dir.join(&rel);
let canonical = canonicalize(&abs);
if !plan.contains_key(&canonical) {
let stem = canonical
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(&dep_name);
let id = unique_id(stem, &mut used_ids);
let filename = canonical
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| format!("{stem}.tgz"));
plan.insert(
canonical.clone(),
Injection {
source_dir: canonical.clone(),
is_tarball: true,
target_dir: injected_root.join(&id),
tarball_filename: filename,
},
);
}
}
aube_lockfile::LocalSource::Git(_)
| aube_lockfile::LocalSource::RemoteTarball(_) => {}
}
}
}
}
Ok(plan)
}
fn iter_strippable_deps(manifest: &PackageJson, strip: StripFields) -> Vec<(String, String)> {
let mut out = Vec::new();
if !strip.dependencies {
for (k, v) in &manifest.dependencies {
out.push((k.clone(), v.clone()));
}
}
if !strip.dev_dependencies {
for (k, v) in &manifest.dev_dependencies {
out.push((k.clone(), v.clone()));
}
}
if !strip.optional_dependencies {
for (k, v) in &manifest.optional_dependencies {
out.push((k.clone(), v.clone()));
}
}
out
}
fn unique_id(seed: &str, used: &mut BTreeMap<String, u32>) -> String {
let cleaned: String = seed
.chars()
.map(|c| {
if matches!(c, '/' | '\\' | ':' | ' ' | '\t') {
'_'
} else {
c
}
})
.collect();
let base = if cleaned.is_empty() {
"pkg".to_string()
} else {
cleaned
};
let count = used.entry(base.clone()).or_insert(0);
*count += 1;
if *count == 1 {
base
} else {
format!("{base}_{count}")
}
}
fn canonicalize(p: &Path) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
}
fn resolve_workspace_spec(spec: &str, concrete_version: &str) -> String {
let suffix = spec.strip_prefix("workspace:").unwrap_or(spec);
match suffix {
"" | "*" => concrete_version.to_string(),
"^" => format!("^{concrete_version}"),
"~" => format!("~{concrete_version}"),
other => other.to_string(),
}
}
fn materialize_injections(
plan: &InjectionPlan,
ws_index: &BTreeMap<String, (PathBuf, Option<String>)>,
deploy_all_files: bool,
) -> miette::Result<()> {
for inj in plan.values() {
std::fs::create_dir_all(&inj.target_dir)
.into_diagnostic()
.wrap_err_with(|| format!("failed to create {}", inj.target_dir.display()))?;
if inj.is_tarball {
let dst = inj.target_dir.join(&inj.tarball_filename);
std::fs::copy(&inj.source_dir, &dst)
.into_diagnostic()
.wrap_err_with(|| {
format!(
"failed to copy {} -> {}",
inj.source_dir.display(),
dst.display()
)
})?;
continue;
}
let source_is_workspace_sibling = ws_index
.values()
.any(|(p, _)| canonicalize(p) == inj.source_dir);
let files: Vec<(PathBuf, String)> = if deploy_all_files && source_is_workspace_sibling {
collect_all_files(&inj.source_dir, &inj.target_dir)?
} else {
let manifest = super::load_manifest(&inj.source_dir.join("package.json"))?;
collect_package_files(&inj.source_dir, &manifest)?
};
for (src, rel) in &files {
let dst = inj.target_dir.join(rel);
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)
.into_diagnostic()
.wrap_err_with(|| format!("failed to create {}", parent.display()))?;
}
std::fs::copy(src, &dst)
.into_diagnostic()
.wrap_err_with(|| {
format!("failed to copy {} -> {}", src.display(), dst.display())
})?;
}
}
Ok(())
}
fn collect_all_files(source: &Path, target: &Path) -> miette::Result<Vec<(PathBuf, String)>> {
let target_canon = std::fs::canonicalize(target).unwrap_or_else(|_| target.to_path_buf());
let mut out = Vec::new();
let mut stack = vec![source.to_path_buf()];
while let Some(dir) = stack.pop() {
let iter = std::fs::read_dir(&dir)
.into_diagnostic()
.wrap_err_with(|| format!("deploy: read_dir({}) failed", dir.display()))?;
for entry in iter {
let entry = entry
.into_diagnostic()
.wrap_err_with(|| format!("deploy: failed to read entry in {}", dir.display()))?;
let name = entry.file_name();
if matches!(name.to_string_lossy().as_ref(), "node_modules" | ".git") {
continue;
}
let path = entry.path();
let canon = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
if canon == target_canon {
continue;
}
let ft = entry
.file_type()
.into_diagnostic()
.wrap_err_with(|| format!("deploy: failed to stat {}", path.display()))?;
let (is_dir, is_file) = if ft.is_symlink() {
match std::fs::metadata(&path) {
Ok(md) => (md.is_dir(), md.is_file()),
Err(_) => (false, false),
}
} else {
(ft.is_dir(), ft.is_file())
};
if is_dir && !ft.is_symlink() {
stack.push(path);
} else if is_file && let Ok(rel) = path.strip_prefix(source) {
out.push((path.clone(), rel.to_string_lossy().replace('\\', "/")));
}
}
}
Ok(out)
}
fn ensure_target_writable(target: &Path) -> miette::Result<()> {
match std::fs::read_dir(target) {
Ok(mut entries) => {
if entries.next().is_some() {
return Err(miette!(
"aube deploy: target directory {} is not empty",
target.display()
));
}
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(miette!(
"aube deploy: failed to inspect {}: {e}",
target.display()
)),
}
}
#[derive(Debug, Clone, Copy, Default)]
struct StripFields {
dependencies: bool,
dev_dependencies: bool,
optional_dependencies: bool,
}
impl StripFields {
fn for_args(args: &DeployArgs) -> Self {
let DepAxis {
prod,
dev,
optional,
} = DepAxis::for_args(args);
Self {
dependencies: !prod,
dev_dependencies: !dev,
optional_dependencies: !optional,
}
}
fn for_bundled_sibling(args: &DeployArgs) -> Self {
Self {
dependencies: false,
dev_dependencies: true,
optional_dependencies: args.no_optional,
}
}
}
#[derive(Debug, Clone, Copy)]
struct DepAxis {
prod: bool,
dev: bool,
optional: bool,
}
impl DepAxis {
fn for_args(args: &DeployArgs) -> Self {
Self {
prod: !args.dev,
dev: args.dev || args.no_prod,
optional: !args.dev && !args.no_optional,
}
}
}
fn dep_selection_for_args(args: &DeployArgs) -> install::DepSelection {
let prod = !args.dev && !args.no_prod;
let dev = args.dev;
install::DepSelection::from_flags(prod, dev, args.no_optional)
}
fn keep_dep_for_args(args: &DeployArgs) -> impl Fn(&aube_lockfile::DirectDep) -> bool + use<> {
let DepAxis {
prod,
dev,
optional,
} = DepAxis::for_args(args);
move |d: &aube_lockfile::DirectDep| match d.dep_type {
aube_lockfile::DepType::Production => prod,
aube_lockfile::DepType::Dev => dev,
aube_lockfile::DepType::Optional => optional,
}
}
#[derive(Debug, Clone, Copy)]
struct DeployRoot<'a> {
deployed_canonical: &'a Path,
target_root: &'a Path,
}
fn resolve_catalog_for_rewrite(
catalogs: &CatalogMap,
pkg_name: &str,
spec: &str,
manifest_path: &Path,
) -> miette::Result<String> {
let catalog_name = spec
.strip_prefix("catalog:")
.map(|n| if n.is_empty() { "default" } else { n })
.ok_or_else(|| {
miette!(
"aube deploy: internal error — resolve_catalog_for_rewrite called on non-catalog spec {spec:?}"
)
})?;
let Some(catalog) = catalogs.get(catalog_name) else {
return Err(miette!(
code = aube_codes::errors::ERR_AUBE_UNKNOWN_CATALOG,
help = "define the catalog in `pnpm-workspace.yaml` or under `pnpm.catalog` / `workspaces.catalog` in `package.json`",
"aube deploy: {} declares `{pkg_name}: {spec}` but catalog `{catalog_name}` is not defined in the source workspace",
manifest_path.display(),
));
};
let Some(value) = catalog.get(pkg_name) else {
return Err(miette!(
code = aube_codes::errors::ERR_AUBE_UNKNOWN_CATALOG_ENTRY,
"aube deploy: {} declares `{pkg_name}: {spec}` but catalog `{catalog_name}` has no entry for {pkg_name:?}",
manifest_path.display(),
));
};
if aube_util::pkg::is_catalog_spec(value) {
return Err(miette!(
code = aube_codes::errors::ERR_AUBE_UNKNOWN_CATALOG_ENTRY,
"aube deploy: catalog `{catalog_name}` entry for {pkg_name:?} is itself a catalog reference ({value:?}); catalogs cannot chain",
));
}
Ok(value.clone())
}
#[allow(clippy::too_many_arguments)]
fn rewrite_local_refs(
manifest_path: &Path,
source_pkg_dir: &Path,
manifest_dir: &Path,
ws_index: &BTreeMap<String, (PathBuf, Option<String>)>,
catalogs: &CatalogMap,
plan: &InjectionPlan,
strip: StripFields,
root: DeployRoot<'_>,
) -> miette::Result<()> {
let raw = std::fs::read_to_string(manifest_path)
.into_diagnostic()
.wrap_err_with(|| format!("failed to read {}", manifest_path.display()))?;
let mut doc: serde_json::Value = serde_json::from_str(&raw)
.into_diagnostic()
.wrap_err_with(|| format!("failed to parse {}", manifest_path.display()))?;
const DEP_FIELDS: &[&str] = &[
"dependencies",
"devDependencies",
"optionalDependencies",
"peerDependencies",
];
let Some(obj) = doc.as_object_mut() else {
return Err(miette!(
"{} did not parse to a JSON object",
manifest_path.display()
));
};
if strip.dependencies {
obj.remove("dependencies");
}
if strip.dev_dependencies {
obj.remove("devDependencies");
}
if strip.optional_dependencies {
obj.remove("optionalDependencies");
}
for field in DEP_FIELDS {
let Some(deps) = obj.get_mut(*field).and_then(|v| v.as_object_mut()) else {
continue;
};
for (name, spec_val) in deps.iter_mut() {
let Some(raw_spec) = spec_val.as_str() else {
continue;
};
let resolved_owned;
let spec: &str = if aube_util::pkg::is_catalog_spec(raw_spec) {
resolved_owned =
resolve_catalog_for_rewrite(catalogs, name, raw_spec, manifest_path)?;
*spec_val = serde_json::Value::String(resolved_owned.clone());
resolved_owned.as_str()
} else {
raw_spec
};
if aube_util::pkg::is_workspace_spec(spec) {
let Some((sibling_dir, sibling_version)) = ws_index.get(name) else {
return Err(miette!(
"aube deploy: {} declares `{name}: {spec}` but no workspace package named {name:?} was found",
manifest_path.display()
));
};
let canonical = canonicalize(sibling_dir);
if canonical == root.deployed_canonical {
*spec_val =
serde_json::Value::String(file_spec_to_dir(manifest_dir, root.target_root));
continue;
}
let Some(inj) = plan.get(&canonical) else {
if *field == "peerDependencies" {
let Some(sibling_version) = sibling_version else {
return Err(miette!(
"aube deploy: workspace package {name:?} has no `version` field, required to rewrite `{name}: {spec}` in {}",
manifest_path.display()
));
};
*spec_val = serde_json::Value::String(resolve_workspace_spec(
spec,
sibling_version,
));
continue;
}
return Err(miette!(
"aube deploy: bundling plan missing entry for workspace sibling {name:?} declared in {}",
manifest_path.display()
));
};
*spec_val = serde_json::Value::String(file_spec_for_injection(manifest_dir, inj));
} else if let Some(local) = aube_lockfile::LocalSource::parse(spec, source_pkg_dir) {
let abs = match &local {
aube_lockfile::LocalSource::Directory(rel)
| aube_lockfile::LocalSource::Link(rel)
| aube_lockfile::LocalSource::Tarball(rel) => source_pkg_dir.join(rel),
aube_lockfile::LocalSource::Git(_)
| aube_lockfile::LocalSource::RemoteTarball(_) => continue,
};
let canonical = canonicalize(&abs);
if canonical == root.deployed_canonical {
*spec_val =
serde_json::Value::String(file_spec_to_dir(manifest_dir, root.target_root));
continue;
}
let Some(inj) = plan.get(&canonical) else {
if *field == "peerDependencies" {
return Err(miette!(
"aube deploy: peerDependencies cannot reference a local `file:`/`link:` target ({name:?} -> {spec:?}) — peers aren't bundled into the deploy and the relative path won't resolve under the target. Promote the peer to a regular dependency or drop the local path."
));
}
return Err(miette!(
"aube deploy: bundling plan missing entry for `{name}: {spec}` declared in {}",
manifest_path.display()
));
};
*spec_val = serde_json::Value::String(file_spec_for_injection(manifest_dir, inj));
}
}
}
let rewritten = serde_json::to_string_pretty(&doc)
.into_diagnostic()
.wrap_err("failed to serialize rewritten package.json")?;
aube_util::fs_atomic::atomic_write(manifest_path, rewritten.as_bytes())
.into_diagnostic()
.wrap_err_with(|| format!("failed to write {}", manifest_path.display()))?;
Ok(())
}
fn file_spec_for_injection(manifest_dir: &Path, inj: &Injection) -> String {
let target_path = if inj.is_tarball {
inj.target_dir.join(&inj.tarball_filename)
} else {
inj.target_dir.clone()
};
file_spec_to_dir(manifest_dir, &target_path)
}
fn file_spec_to_dir(manifest_dir: &Path, target: &Path) -> String {
let rel = pathdiff::diff_paths(target, manifest_dir).unwrap_or_else(|| target.to_path_buf());
let mut s = rel.to_string_lossy().replace('\\', "/");
if s.is_empty() {
s = ".".to_string();
}
if !s.starts_with("./") && !s.starts_with("../") && !s.starts_with('/') {
s = format!("./{s}");
}
format!("file:{s}")
}
#[cfg(test)]
mod tests {
use super::*;
fn ws_index(entries: &[(&str, &str)]) -> BTreeMap<String, (PathBuf, Option<String>)> {
entries
.iter()
.map(|(n, v)| {
(
(*n).to_string(),
(PathBuf::from("/tmp"), Some((*v).to_string())),
)
})
.collect()
}
fn deploy_args() -> DeployArgs {
DeployArgs {
target: PathBuf::from("/tmp/unused"),
dev: false,
no_optional: false,
prod: false,
no_prod: false,
offline: false,
prefer_offline: false,
lockfile: crate::cli_args::LockfileArgs::default(),
network: crate::cli_args::NetworkArgs::default(),
virtual_store: crate::cli_args::VirtualStoreArgs::default(),
}
}
#[test]
fn dep_selection_default_is_prod() {
let a = deploy_args();
assert_eq!(dep_selection_for_args(&a), install::DepSelection::Prod);
}
#[test]
fn dep_selection_no_prod_is_all() {
let a = DeployArgs {
no_prod: true,
..deploy_args()
};
assert_eq!(dep_selection_for_args(&a), install::DepSelection::All);
}
#[test]
fn dep_selection_no_prod_and_no_optional_is_no_optional() {
let a = DeployArgs {
no_prod: true,
no_optional: true,
..deploy_args()
};
assert_eq!(
dep_selection_for_args(&a),
install::DepSelection::NoOptional
);
}
#[test]
fn dep_selection_covers_every_flag_combo() {
let cases: &[(bool, bool, bool, install::DepSelection)] = &[
(false, false, false, install::DepSelection::Prod),
(false, false, true, install::DepSelection::ProdNoOptional),
(false, true, false, install::DepSelection::All),
(false, true, true, install::DepSelection::NoOptional),
(true, false, false, install::DepSelection::Dev),
(true, false, true, install::DepSelection::DevNoOptional),
];
for &(dev, no_prod, no_optional, want) in cases {
let a = DeployArgs {
dev,
no_prod,
no_optional,
..deploy_args()
};
assert_eq!(
dep_selection_for_args(&a),
want,
"dev={dev} no_prod={no_prod} no_optional={no_optional}"
);
}
}
#[test]
fn strip_default_drops_dev_keeps_prod_and_optional() {
let s = StripFields::for_args(&deploy_args());
assert!(!s.dependencies);
assert!(s.dev_dependencies);
assert!(!s.optional_dependencies);
}
#[test]
fn strip_no_prod_keeps_everything() {
let a = DeployArgs {
no_prod: true,
..deploy_args()
};
let s = StripFields::for_args(&a);
assert!(!s.dependencies);
assert!(!s.dev_dependencies);
assert!(!s.optional_dependencies);
}
#[test]
fn strip_dev_only_drops_prod_and_optional() {
let a = DeployArgs {
dev: true,
..deploy_args()
};
let s = StripFields::for_args(&a);
assert!(s.dependencies);
assert!(!s.dev_dependencies);
assert!(s.optional_dependencies);
}
#[test]
fn strip_for_bundled_sibling_always_drops_dev() {
let s = StripFields::for_bundled_sibling(&DeployArgs {
no_prod: true,
..deploy_args()
});
assert!(s.dev_dependencies);
assert!(!s.dependencies);
assert!(!s.optional_dependencies);
}
#[test]
fn rewrite_local_refs_drops_workspace_dep_when_field_stripped() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("package.json");
std::fs::write(
&path,
r#"{"name":"x","version":"1.0.0","dependencies":{"lodash":"^4"},"devDependencies":{"@test/internal":"workspace:*"}}"#,
)
.unwrap();
let idx = ws_index(&[]); let plan = InjectionPlan::new();
let stub = PathBuf::from("/nonexistent-deployed");
rewrite_local_refs(
&path,
tmp.path(),
tmp.path(),
&idx,
&CatalogMap::new(),
&plan,
StripFields {
dependencies: false,
dev_dependencies: true,
optional_dependencies: false,
},
DeployRoot {
deployed_canonical: &stub,
target_root: tmp.path(),
},
)
.unwrap();
let out: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(out.get("devDependencies").is_none());
assert_eq!(out["dependencies"]["lodash"], "^4");
}
#[test]
fn rewrite_local_refs_writes_relative_file_spec_for_workspace_sibling() {
let tmp = tempfile::tempdir().unwrap();
let manifest_dir = tmp.path();
let sibling_dir = tmp.path().join("packages/lib");
std::fs::create_dir_all(&sibling_dir).unwrap();
let injected_dir = manifest_dir.join(".aube-deploy-injected").join("lib");
std::fs::create_dir_all(&injected_dir).unwrap();
let path = manifest_dir.join("package.json");
std::fs::write(
&path,
r#"{"name":"x","version":"1.0.0","dependencies":{"@test/lib":"workspace:*"}}"#,
)
.unwrap();
let mut idx = BTreeMap::new();
idx.insert(
"@test/lib".to_string(),
(sibling_dir.clone(), Some("1.2.3".to_string())),
);
let mut plan = InjectionPlan::new();
plan.insert(
canonicalize(&sibling_dir),
Injection {
source_dir: sibling_dir.clone(),
is_tarball: false,
target_dir: injected_dir.clone(),
tarball_filename: String::new(),
},
);
let stub = PathBuf::from("/nonexistent-deployed");
rewrite_local_refs(
&path,
manifest_dir,
manifest_dir,
&idx,
&CatalogMap::new(),
&plan,
StripFields::default(),
DeployRoot {
deployed_canonical: &stub,
target_root: manifest_dir,
},
)
.unwrap();
let out: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(
out["dependencies"]["@test/lib"],
"file:./.aube-deploy-injected/lib"
);
}
#[test]
fn rewrite_local_refs_resolves_workspace_peer_to_concrete_range() {
let tmp = tempfile::tempdir().unwrap();
let manifest_dir = tmp.path();
let sibling_dir = tmp.path().join("packages/lib");
std::fs::create_dir_all(&sibling_dir).unwrap();
let path = manifest_dir.join("package.json");
std::fs::write(
&path,
r#"{
"name":"x",
"version":"1.0.0",
"peerDependencies":{
"@test/lib":"workspace:*",
"@test/lib-caret":"workspace:^",
"@test/lib-tilde":"workspace:~",
"@test/lib-literal":"workspace:^2.0.0"
}
}"#,
)
.unwrap();
let mut idx = BTreeMap::new();
for n in [
"@test/lib",
"@test/lib-caret",
"@test/lib-tilde",
"@test/lib-literal",
] {
idx.insert(
n.to_string(),
(sibling_dir.clone(), Some("1.2.3".to_string())),
);
}
let stub = PathBuf::from("/nonexistent-deployed");
rewrite_local_refs(
&path,
manifest_dir,
manifest_dir,
&idx,
&CatalogMap::new(),
&InjectionPlan::new(),
StripFields::default(),
DeployRoot {
deployed_canonical: &stub,
target_root: manifest_dir,
},
)
.unwrap();
let out: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let peers = &out["peerDependencies"];
assert_eq!(peers["@test/lib"], "1.2.3");
assert_eq!(peers["@test/lib-caret"], "^1.2.3");
assert_eq!(peers["@test/lib-tilde"], "~1.2.3");
assert_eq!(peers["@test/lib-literal"], "^2.0.0");
}
#[test]
fn rewrite_local_refs_writes_back_ref_to_target_root_for_deployed_pkg() {
let tmp = tempfile::tempdir().unwrap();
let target_root = tmp.path();
let deployed_dir = target_root.join("source/deployed-pkg");
std::fs::create_dir_all(&deployed_dir).unwrap();
let deployed_canonical = canonicalize(&deployed_dir);
let sibling_target = target_root.join(".aube-deploy-injected").join("b");
std::fs::create_dir_all(&sibling_target).unwrap();
let sibling_manifest = sibling_target.join("package.json");
std::fs::write(
&sibling_manifest,
r#"{"name":"@test/b","version":"1.0.0","dependencies":{"@deployed/pkg":"workspace:*"}}"#,
)
.unwrap();
let mut idx = BTreeMap::new();
idx.insert(
"@deployed/pkg".to_string(),
(deployed_canonical.clone(), Some("9.9.9".to_string())),
);
rewrite_local_refs(
&sibling_manifest,
&deployed_canonical,
&sibling_target,
&idx,
&CatalogMap::new(),
&InjectionPlan::new(),
StripFields::default(),
DeployRoot {
deployed_canonical: &deployed_canonical,
target_root,
},
)
.unwrap();
let out: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&sibling_manifest).unwrap()).unwrap();
assert_eq!(out["dependencies"]["@deployed/pkg"], "file:../..");
}
#[test]
fn rewrite_local_refs_writes_back_ref_for_file_link_to_deployed_pkg() {
let tmp = tempfile::tempdir().unwrap();
let target_root = tmp.path();
let deployed_dir = target_root.join("source/deployed-pkg");
std::fs::create_dir_all(&deployed_dir).unwrap();
let deployed_canonical = canonicalize(&deployed_dir);
let sibling_target = target_root.join(".aube-deploy-injected").join("b");
std::fs::create_dir_all(&sibling_target).unwrap();
let sibling_manifest = sibling_target.join("package.json");
std::fs::write(
&sibling_manifest,
r#"{"name":"@test/b","version":"1.0.0","dependencies":{"@deployed/pkg":"file:../../source/deployed-pkg"}}"#,
)
.unwrap();
rewrite_local_refs(
&sibling_manifest,
&deployed_canonical,
&sibling_target,
&BTreeMap::new(),
&CatalogMap::new(),
&InjectionPlan::new(),
StripFields::default(),
DeployRoot {
deployed_canonical: &deployed_canonical,
target_root,
},
)
.unwrap();
let out: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&sibling_manifest).unwrap()).unwrap();
assert_eq!(out["dependencies"]["@deployed/pkg"], "file:../..");
}
#[test]
fn rewrite_local_refs_errors_on_file_peer_dep() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("package.json");
std::fs::write(
&path,
r#"{"name":"x","version":"1.0.0","peerDependencies":{"vendor":"file:../local-vendor"}}"#,
)
.unwrap();
let stub = PathBuf::from("/nonexistent-deployed");
let err = rewrite_local_refs(
&path,
tmp.path(),
tmp.path(),
&BTreeMap::new(),
&CatalogMap::new(),
&InjectionPlan::new(),
StripFields::default(),
DeployRoot {
deployed_canonical: &stub,
target_root: tmp.path(),
},
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("peerDependencies"), "msg was: {msg}");
assert!(msg.contains("vendor"), "msg was: {msg}");
}
#[test]
fn rewrite_local_refs_errors_on_unknown_workspace_ref() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("package.json");
std::fs::write(
&path,
r#"{"name":"x","version":"1.0.0","dependencies":{"@test/missing":"workspace:*"}}"#,
)
.unwrap();
let idx = ws_index(&[]);
let plan = InjectionPlan::new();
let stub = PathBuf::from("/nonexistent-deployed");
let err = rewrite_local_refs(
&path,
tmp.path(),
tmp.path(),
&idx,
&CatalogMap::new(),
&plan,
StripFields::default(),
DeployRoot {
deployed_canonical: &stub,
target_root: tmp.path(),
},
)
.unwrap_err();
assert!(err.to_string().contains("@test/missing"));
}
fn catalog_map(entries: &[(&str, &[(&str, &str)])]) -> CatalogMap {
let mut m = CatalogMap::new();
for (cat_name, pkgs) in entries {
let mut inner = BTreeMap::new();
for (pkg, range) in *pkgs {
inner.insert((*pkg).to_string(), (*range).to_string());
}
m.insert((*cat_name).to_string(), inner);
}
m
}
#[test]
fn rewrite_local_refs_resolves_catalog_default() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("package.json");
std::fs::write(
&path,
r#"{
"name":"x","version":"1.0.0",
"dependencies":{
"drizzle-orm":"catalog:",
"zod":"catalog:default"
}
}"#,
)
.unwrap();
let cats = catalog_map(&[(
"default",
&[("drizzle-orm", "1.0.0-rc.1"), ("zod", "4.4.2")],
)]);
let stub = PathBuf::from("/nonexistent-deployed");
rewrite_local_refs(
&path,
tmp.path(),
tmp.path(),
&BTreeMap::new(),
&cats,
&InjectionPlan::new(),
StripFields::default(),
DeployRoot {
deployed_canonical: &stub,
target_root: tmp.path(),
},
)
.unwrap();
let out: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(out["dependencies"]["drizzle-orm"], "1.0.0-rc.1");
assert_eq!(out["dependencies"]["zod"], "4.4.2");
}
#[test]
fn rewrite_local_refs_resolves_named_catalog() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("package.json");
std::fs::write(
&path,
r#"{"name":"x","version":"1.0.0","dependencies":{"react":"catalog:evens"}}"#,
)
.unwrap();
let cats = catalog_map(&[("evens", &[("react", "18.2.0")])]);
let stub = PathBuf::from("/nonexistent-deployed");
rewrite_local_refs(
&path,
tmp.path(),
tmp.path(),
&BTreeMap::new(),
&cats,
&InjectionPlan::new(),
StripFields::default(),
DeployRoot {
deployed_canonical: &stub,
target_root: tmp.path(),
},
)
.unwrap();
let out: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(out["dependencies"]["react"], "18.2.0");
}
#[test]
fn rewrite_local_refs_errors_on_unknown_catalog() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("package.json");
std::fs::write(
&path,
r#"{"name":"x","version":"1.0.0","dependencies":{"drizzle-orm":"catalog:"}}"#,
)
.unwrap();
let stub = PathBuf::from("/nonexistent-deployed");
let err = rewrite_local_refs(
&path,
tmp.path(),
tmp.path(),
&BTreeMap::new(),
&CatalogMap::new(),
&InjectionPlan::new(),
StripFields::default(),
DeployRoot {
deployed_canonical: &stub,
target_root: tmp.path(),
},
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("catalog `default`"), "msg was: {msg}");
assert!(msg.contains("drizzle-orm"), "msg was: {msg}");
}
#[test]
fn rewrite_local_refs_errors_on_missing_catalog_entry() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("package.json");
std::fs::write(
&path,
r#"{"name":"x","version":"1.0.0","dependencies":{"drizzle-orm":"catalog:"}}"#,
)
.unwrap();
let cats = catalog_map(&[("default", &[("zod", "4.4.2")])]);
let stub = PathBuf::from("/nonexistent-deployed");
let err = rewrite_local_refs(
&path,
tmp.path(),
tmp.path(),
&BTreeMap::new(),
&cats,
&InjectionPlan::new(),
StripFields::default(),
DeployRoot {
deployed_canonical: &stub,
target_root: tmp.path(),
},
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("has no entry"), "msg was: {msg}");
assert!(msg.contains("drizzle-orm"), "msg was: {msg}");
}
#[test]
fn rewrite_local_refs_errors_on_chained_catalog() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("package.json");
std::fs::write(
&path,
r#"{"name":"x","version":"1.0.0","dependencies":{"react":"catalog:"}}"#,
)
.unwrap();
let cats = catalog_map(&[("default", &[("react", "catalog:other")])]);
let stub = PathBuf::from("/nonexistent-deployed");
let err = rewrite_local_refs(
&path,
tmp.path(),
tmp.path(),
&BTreeMap::new(),
&cats,
&InjectionPlan::new(),
StripFields::default(),
DeployRoot {
deployed_canonical: &stub,
target_root: tmp.path(),
},
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("catalogs cannot chain"), "msg was: {msg}");
}
#[test]
fn rewrite_local_refs_catalog_resolves_to_workspace_then_to_file_ref() {
let tmp = tempfile::tempdir().unwrap();
let manifest_dir = tmp.path();
let sibling_dir = tmp.path().join("packages/lib");
std::fs::create_dir_all(&sibling_dir).unwrap();
let injected_dir = manifest_dir.join(".aube-deploy-injected").join("lib");
std::fs::create_dir_all(&injected_dir).unwrap();
let path = manifest_dir.join("package.json");
std::fs::write(
&path,
r#"{"name":"x","version":"1.0.0","dependencies":{"@test/lib":"catalog:"}}"#,
)
.unwrap();
let mut idx = BTreeMap::new();
idx.insert(
"@test/lib".to_string(),
(sibling_dir.clone(), Some("1.2.3".to_string())),
);
let mut plan = InjectionPlan::new();
plan.insert(
canonicalize(&sibling_dir),
Injection {
source_dir: sibling_dir.clone(),
is_tarball: false,
target_dir: injected_dir.clone(),
tarball_filename: String::new(),
},
);
let cats = catalog_map(&[("default", &[("@test/lib", "workspace:*")])]);
let stub = PathBuf::from("/nonexistent-deployed");
rewrite_local_refs(
&path,
manifest_dir,
manifest_dir,
&idx,
&cats,
&plan,
StripFields::default(),
DeployRoot {
deployed_canonical: &stub,
target_root: manifest_dir,
},
)
.unwrap();
let out: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(
out["dependencies"]["@test/lib"],
"file:./.aube-deploy-injected/lib"
);
}
#[test]
fn file_spec_for_injection_emits_relative_directory_path() {
let manifest_dir = PathBuf::from("/tmp/deploy/out");
let target_dir = PathBuf::from("/tmp/deploy/out/.aube-deploy-injected/lib");
let inj = Injection {
source_dir: PathBuf::from("/src/lib"),
is_tarball: false,
target_dir,
tarball_filename: String::new(),
};
assert_eq!(
file_spec_for_injection(&manifest_dir, &inj),
"file:./.aube-deploy-injected/lib"
);
}
#[test]
fn file_spec_for_injection_emits_relative_tarball_path() {
let manifest_dir = PathBuf::from("/tmp/deploy/out");
let target_dir = PathBuf::from("/tmp/deploy/out/.aube-deploy-injected/foo");
let inj = Injection {
source_dir: PathBuf::from("/src/foo.tgz"),
is_tarball: true,
target_dir,
tarball_filename: "foo.tgz".to_string(),
};
assert_eq!(
file_spec_for_injection(&manifest_dir, &inj),
"file:./.aube-deploy-injected/foo/foo.tgz"
);
}
#[test]
fn file_spec_for_injection_emits_dotdot_for_sibling_in_injected_dir() {
let manifest_dir = PathBuf::from("/tmp/deploy/out/.aube-deploy-injected/lib");
let target_dir = PathBuf::from("/tmp/deploy/out/.aube-deploy-injected/core");
let inj = Injection {
source_dir: PathBuf::from("/src/core"),
is_tarball: false,
target_dir,
tarball_filename: String::new(),
};
assert_eq!(file_spec_for_injection(&manifest_dir, &inj), "file:../core");
}
#[test]
fn unique_id_disambiguates_collisions() {
let mut used = BTreeMap::new();
assert_eq!(unique_id("lib", &mut used), "lib");
assert_eq!(unique_id("lib", &mut used), "lib_2");
}
#[test]
fn unique_id_sanitizes_unsafe_chars() {
let mut used = BTreeMap::new();
assert_eq!(unique_id("@scope/name", &mut used), "@scope_name");
}
#[test]
fn ensure_target_writable_empty_dir_is_ok() {
let tmp = tempfile::tempdir().unwrap();
ensure_target_writable(tmp.path()).unwrap();
}
#[test]
fn ensure_target_writable_missing_is_ok() {
let tmp = tempfile::tempdir().unwrap();
ensure_target_writable(&tmp.path().join("nope")).unwrap();
}
#[test]
fn ensure_target_writable_nonempty_errors() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("stuff"), "hi").unwrap();
assert!(ensure_target_writable(tmp.path()).is_err());
}
}