1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
use super::install;
use clap::Args;
use miette::{Context, IntoDiagnostic, miette};
#[derive(Debug, Clone, Args)]
pub struct RemoveArgs {
/// Package(s) to remove
pub packages: Vec<String>,
/// Remove only from devDependencies
#[arg(short = 'D', long)]
pub save_dev: bool,
/// Remove from the global install directory instead of the project
#[arg(short = 'g', long)]
pub global: bool,
/// Skip root lifecycle scripts during the chained reinstall
#[arg(long)]
pub ignore_scripts: bool,
/// Remove the dependency from the workspace root's `package.json`,
/// regardless of the current working directory.
///
/// Walks up from cwd looking for `aube-workspace.yaml`,
/// `pnpm-workspace.yaml`, or a `package.json` with a `workspaces`
/// field and runs the remove against that directory. Takes
/// precedence over `--filter` when both are supplied (same as
/// `add --workspace`).
#[arg(short = 'w', long, conflicts_with = "global")]
pub workspace: bool,
}
pub async fn run(
args: RemoveArgs,
filter: aube_workspace::selector::EffectiveFilter,
) -> miette::Result<()> {
let packages = &args.packages[..];
if packages.is_empty() {
return Err(miette!("no packages specified"));
}
if !filter.is_empty() && !args.global && !args.workspace {
return run_filtered(args, &filter).await;
}
if args.global {
return run_global(packages);
}
// `--workspace` / `-w`: redirect the remove at the workspace root
// before anything reads `dirs::cwd()`.
if args.workspace {
let start = std::env::current_dir()
.into_diagnostic()
.wrap_err("failed to read current dir")?;
let root = super::find_workspace_root(&start).wrap_err("--workspace")?;
if root != start {
std::env::set_current_dir(&root)
.into_diagnostic()
.wrap_err_with(|| format!("failed to chdir into {}", root.display()))?;
}
crate::dirs::set_cwd(&root)?;
}
let cwd = crate::dirs::project_root()?;
let _lock = super::take_project_lock(&cwd)?;
let manifest_path = cwd.join("package.json");
let mut manifest = super::load_manifest(&manifest_path)?;
for name in packages {
let removed = if args.save_dev {
manifest.dev_dependencies.remove(name).is_some()
} else {
// Strip from every section. `--save-peer` previously
// wrote to both peerDependencies and devDependencies so
// both need clearing on a full uninstall.
let from_deps = manifest.dependencies.remove(name).is_some();
let from_dev = manifest.dev_dependencies.remove(name).is_some();
let from_optional = manifest.optional_dependencies.remove(name).is_some();
let from_peer = manifest.peer_dependencies.remove(name).is_some();
from_deps || from_dev || from_optional || from_peer
};
// Also prune sidecar metadata so a later `aube add <name>`
// does not silently inherit the old entries. Main concern is
// pnpm.allowBuilds. If user removes a build-script package
// then later adds a malicious package with the same name
// (typo-squat, name reclaim), the old allowBuilds entry
// would auto-approve its postinstall. Same hazard, lower
// risk, for overrides and resolutions which just leave dead
// rewrite rules around. Matches pnpm remove behavior.
prune_sidecar_entries(&mut manifest, name);
if !removed {
let section = if args.save_dev {
"a devDependency"
} else {
"a dependency"
};
return Err(miette!("package '{name}' is not {section}"));
}
eprintln!(" - {name}");
}
// Write updated package.json atomically. Crash mid-write would
// otherwise truncate the user manifest, worst-case aube failure
// mode. Tempfile + persist keeps the swap atomic.
super::write_manifest_json(&manifest_path, &manifest)?;
eprintln!("Updated package.json");
// Re-resolve dependency tree without the removed packages
let existing = aube_lockfile::parse_lockfile(&cwd, &manifest).ok();
let workspace_catalogs = super::load_workspace_catalogs(&cwd)?;
let mut resolver = super::build_resolver(&cwd, &manifest, workspace_catalogs);
let graph = resolver
.resolve(&manifest, existing.as_ref())
.await
.map_err(miette::Report::new)
.wrap_err("failed to resolve dependencies")?;
eprintln!("Resolved {} packages", graph.packages.len());
super::write_and_log_lockfile(&cwd, &graph, &manifest)?;
// Reinstall to clean up node_modules
let mut opts =
install::InstallOptions::with_mode(super::chained_frozen_mode(install::FrozenMode::Prefer));
opts.ignore_scripts = args.ignore_scripts;
install::run(opts).await?;
Ok(())
}
async fn run_filtered(
args: RemoveArgs,
filter: &aube_workspace::selector::EffectiveFilter,
) -> miette::Result<()> {
let cwd = crate::dirs::cwd()?;
let (_root, matched) = super::select_workspace_packages(&cwd, filter, "remove")?;
let result = async {
for pkg in matched {
super::retarget_cwd(&pkg.dir)?;
Box::pin(run(
args.clone(),
aube_workspace::selector::EffectiveFilter::default(),
))
.await?;
}
Ok(())
}
.await;
super::finish_filtered_workspace(&cwd, result)
}
/// `aube remove -g <pkg>...` — delete globally-installed packages and
/// unlink their bins. Each named package is looked up in the global pkg
/// dir; if found, the whole install (hash symlink + physical dir + bins)
/// is removed atomically.
fn run_global(packages: &[String]) -> miette::Result<()> {
let layout = super::global::GlobalLayout::resolve()?;
let mut any_removed = false;
for name in packages {
match super::global::find_package(&layout.pkg_dir, name) {
Some(info) => {
super::global::remove_package(&info, &layout)?;
eprintln!("Removed global {name}");
any_removed = true;
}
None => {
eprintln!("Not globally installed: {name}");
}
}
}
if !any_removed {
return Err(miette!("no matching global packages were removed"));
}
Ok(())
}
/// Prune aube/pnpm sidecar metadata entries that reference `name`.
/// Covers pnpm.allowBuilds, pnpm.onlyBuiltDependencies,
/// pnpm.neverBuiltDependencies, pnpm.overrides, aube.* mirrors,
/// top-level overrides, yarn resolutions. Also removes the whole
/// namespace block if its last entry was the one we just dropped.
/// Safe no-op if the manifest has none of these fields.
fn prune_sidecar_entries(manifest: &mut aube_manifest::PackageJson, name: &str) {
// Namespaced (pnpm.* / aube.*) allowlists, overrides, denylists.
for ns_key in ["pnpm", "aube"] {
let Some(ns) = manifest.extra.get_mut(ns_key) else {
continue;
};
let Some(obj) = ns.as_object_mut() else {
continue;
};
// Map-shape fields: key is package name.
for map_key in ["allowBuilds", "overrides", "peerDependencyRules"] {
if let Some(inner) = obj.get_mut(map_key).and_then(|v| v.as_object_mut()) {
inner.remove(name);
// peerDependencyRules has nested allowedVersions,
// ignoreMissing. Only clean the outer pkg-keyed
// entries, deeper structures are author-controlled.
if inner.is_empty() {
obj.remove(map_key);
}
}
}
// Array-shape fields: whole entries match name or name@ver.
for arr_key in [
"onlyBuiltDependencies",
"neverBuiltDependencies",
"trustedDependencies",
] {
if let Some(arr) = obj.get_mut(arr_key).and_then(|v| v.as_array_mut()) {
arr.retain(|entry| match entry.as_str() {
Some(s) => {
// "pkg" stays only if it is not our name.
// "pkg@range" stays only if pkg is not ours.
let base = s.rsplit_once('@').map(|(a, _)| a).unwrap_or(s);
base != name
}
None => true,
});
if arr.is_empty() {
obj.remove(arr_key);
}
}
}
// Drop the whole pnpm/aube block if we emptied it completely.
if obj.is_empty() {
manifest.extra.remove(ns_key);
}
}
// Top-level `overrides` (npm + pnpm both accept it here).
if let Some(top) = manifest
.extra
.get_mut("overrides")
.and_then(|v| v.as_object_mut())
{
top.remove(name);
if top.is_empty() {
manifest.extra.remove("overrides");
}
}
// yarn `resolutions` at top level.
if let Some(top) = manifest
.extra
.get_mut("resolutions")
.and_then(|v| v.as_object_mut())
{
top.remove(name);
if top.is_empty() {
manifest.extra.remove("resolutions");
}
}
}