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
//! `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 each project's `node_modules/`.
//! - With `--lockfile` / `-l`, also remove the root lockfiles:
//! `aube-lock.yaml`, `pnpm-lock.yaml`, `package-lock.json`,
//! `npm-shrinkwrap.json`, and `yarn.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
/// (`aube-lock.yaml`, `pnpm-lock.yaml`, `package-lock.json`,
/// `npm-shrinkwrap.json`, `yarn.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.
const LOCKFILE_NAMES: &[&str] = &[
"aube-lock.yaml",
"pnpm-lock.yaml",
"package-lock.json",
"npm-shrinkwrap.json",
"yarn.lock",
];
pub async fn run(args: CleanArgs) -> miette::Result<()> {
run_as("clean", args).await
}
pub async fn run_purge(args: CleanArgs) -> miette::Result<()> {
run_as("purge", args).await
}
async fn run_as(invoked_as: &str, args: CleanArgs) -> miette::Result<()> {
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 = aube_manifest::PackageJson::from_path(&root_pkg)
.into_diagnostic()
.wrap_err("failed to read package.json")?;
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);
// `symlink_metadata` so a `node_modules -> somewhere` symlink
// gets removed as a link (not followed into its target).
if nm.symlink_metadata().is_ok() {
eprintln!("Removing {}", nm.display());
super::remove_existing(&nm)?;
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 = if removed_nm == 1 {
"directory"
} else {
"directories"
};
// 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 {
let lock_word = if removed_locks == 1 {
"lockfile"
} else {
"lockfiles"
};
eprintln!("Removed {removed_nm} node_modules {nm_word}, {removed_locks} {lock_word}");
} else {
eprintln!("Removed {removed_nm} node_modules {nm_word}");
}
}
Ok(())
}