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
//! `aube clean` / `aube purge` — remove `node_modules/` across the
//! workspace, optionally wiping lockfiles too.
//!
//! Semantics (matches pnpm):
//! - If the root `package.json` defines a `clean` (or `purge`, when
//! invoked as `aube purge`) script, delegate to `aube run <name>` and
//! do nothing else. User scripts always win.
//! - Otherwise, walk the workspace (root + every package matched by
//! `pnpm-workspace.yaml`) and delete installed packages plus aube-owned
//! hidden entries from each project's `node_modules/`. Third-party hidden
//! entries such as `.vite/` are preserved.
//! - With `--lockfile` / `-l`, also remove the root lockfiles:
//! `aube-lock.yaml`, `pnpm-lock.yaml`, `package-lock.json`,
//! `npm-shrinkwrap.json`, `yarn.lock`, and `bun.lock`. Workspace
//! children don't carry their own lockfile so the flag only touches
//! the root.
//!
//! Unlike `aube ci`, `clean` never reinstalls — it's a pure "wipe the
//! tree" command.
use clap::Args;
use miette::{Context, IntoDiagnostic};
#[derive(Debug, Args)]
pub struct CleanArgs {
/// Also remove lockfiles at the workspace root.
///
/// Targets `aube-lock.yaml`, `pnpm-lock.yaml`, `package-lock.json`,
/// `npm-shrinkwrap.json`, `yarn.lock`, and `bun.lock`.
#[arg(short = 'l', long)]
pub lockfile: bool,
}
/// Lockfile basenames removed by `--lockfile`. Kept in one place so
/// `clean` and any future `purge`-adjacent command see the same set.
/// Lockfile names `aube clean --lockfile` removes: this tool's canonical
/// lockfile plus every foreign format it round-trips. Standalone aube leads
/// with `aube-lock.yaml`.
fn lockfile_names() -> [&'static str; 6] {
[
aube_util::embedder().lockfile_basename,
"pnpm-lock.yaml",
"package-lock.json",
"npm-shrinkwrap.json",
"yarn.lock",
"bun.lock",
]
}
fn is_managed_modules_entry(name: &std::ffi::OsStr) -> bool {
let name = name.to_string_lossy();
let Some(hidden_name) = name.strip_prefix('.') else {
return true;
};
let embedder_name = aube_util::embedder().name;
hidden_name == "bin"
|| hidden_name == "modules.yaml"
|| hidden_name == "pnpm"
|| hidden_name == "pnpm-workspace-state-v1.json"
|| hidden_name
.strip_prefix(embedder_name)
.is_some_and(|suffix| matches!(suffix, "" | "-state" | "-applied-patches.json"))
}
pub async fn run(args: CleanArgs) -> miette::Result<Option<i32>> {
run_as("clean", args).await
}
pub async fn run_purge(args: CleanArgs) -> miette::Result<Option<i32>> {
run_as("purge", args).await
}
async fn run_as(invoked_as: &str, args: CleanArgs) -> miette::Result<Option<i32>> {
let cwd = crate::dirs::project_root()?;
let _lock = super::take_project_lock(&cwd)?;
// pnpm lets the user override `clean` / `purge` with a package.json
// script of the same name. If one exists at the root, delegate and
// stop — the user's script is authoritative.
let root_pkg = cwd.join("package.json");
if root_pkg.is_file() {
let manifest = super::load_manifest(&root_pkg)?;
if manifest.scripts.contains_key(invoked_as) {
// `--lockfile` is an aube-specific flag that the user's
// script almost certainly doesn't know about, so warn
// loudly that we're handing control over without acting
// on the flag ourselves.
if args.lockfile {
eprintln!(
"warning: --lockfile ignored because a `{invoked_as}` script in package.json takes precedence"
);
}
return super::run::run_script(
invoked_as,
&[],
true,
false,
&aube_workspace::selector::EffectiveFilter::default(),
)
.await;
}
}
// Collect every project dir: workspace root plus any packages
// matched by `pnpm-workspace.yaml`. `find_workspace_packages` already
// returns absolute paths and silently returns an empty vec for
// non-workspace projects, so this works for single-package repos too.
// A `HashSet` keeps dedup O(n) for large workspaces; we still push
// to a `Vec` so we preserve the root-first, then discovery-order
// walk the output summary relies on.
let mut seen: std::collections::HashSet<std::path::PathBuf> =
std::collections::HashSet::from([cwd.clone()]);
let mut projects: Vec<std::path::PathBuf> = vec![cwd.clone()];
match aube_workspace::find_workspace_packages(&cwd) {
Ok(ws) => {
for p in ws {
if seen.insert(p.clone()) {
projects.push(p);
}
}
}
Err(e) => {
tracing::debug!("skipping workspace discovery: {e}");
}
}
let mut removed_nm = 0usize;
let mut removed_locks = 0usize;
// Resolve once against the workspace root — `clean` sweeps every
// workspace package but they all share the same `modulesDir`
// (read from `.npmrc` / `aube-workspace.yaml`), so we don't need
// a per-project lookup.
let modules_dir_name = super::resolve_modules_dir_name_for_cwd(&cwd);
for proj in &projects {
let nm = proj.join(&modules_dir_name);
let metadata = match nm.symlink_metadata() {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => {
return Err(error)
.into_diagnostic()
.wrap_err_with(|| format!("failed to inspect {}", nm.display()));
}
};
// A non-directory or directory link is wholly install-managed.
// Remove the entry itself without traversing a symlink/junction target.
if !metadata.is_dir() || super::is_link_or_junction_metadata(&metadata) {
eprintln!("Removing {}", nm.display());
super::remove_existing(&nm)?;
removed_nm += 1;
continue;
}
let mut managed_entries = Vec::new();
for entry in std::fs::read_dir(&nm)
.into_diagnostic()
.wrap_err_with(|| format!("failed to read {}", nm.display()))?
{
let entry = entry
.into_diagnostic()
.wrap_err_with(|| format!("failed to read an entry in {}", nm.display()))?;
if is_managed_modules_entry(&entry.file_name()) {
managed_entries.push(entry.path());
}
}
if managed_entries.is_empty() {
continue;
}
eprintln!("Removing {}", nm.display());
for entry in managed_entries {
super::remove_existing(&entry)?;
}
removed_nm += 1;
}
if args.lockfile {
for name in lockfile_names() {
let p = cwd.join(name);
if p.symlink_metadata().is_ok() {
eprintln!("Removing {}", p.display());
super::remove_existing(&p)?;
removed_locks += 1;
}
}
}
if removed_nm == 0 && removed_locks == 0 {
eprintln!("Nothing to clean");
} else {
let nm_word = pluralizer::pluralize("directory", removed_nm as isize, false);
// Only mention lockfiles when we actually removed at least one
// — "Removed 1 node_modules directory, 0 lockfiles" was just
// noise when `--lockfile` was passed against a tree that had
// no lockfile to begin with.
if removed_locks > 0 {
eprintln!(
"Removed {removed_nm} node_modules {nm_word}, {}",
pluralizer::pluralize("lockfile", removed_locks as isize, true)
);
} else {
eprintln!("Removed {removed_nm} node_modules {nm_word}");
}
}
Ok(None)
}