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
//! `aube ignored-builds` — print packages whose lifecycle scripts were
//! skipped by the `pnpm.allowBuilds` allowlist.
//!
//! Walks the lockfile, reads each dep's stored `package.json` from the
//! global store, and reports any package that declares a
//! `preinstall` / `install` / `postinstall` script but isn't explicitly
//! allowed by the current `BuildPolicy`. Shared with `approve-builds`,
//! which re-uses [`collect_ignored`] to drive its interactive picker.
//!
//! Pure read — no network, no writes, no project lock.
use clap::Args;
use miette::{Context, IntoDiagnostic};
use std::collections::BTreeSet;
pub const AFTER_LONG_HELP: &str = "\
Examples:
$ aube ignored-builds
The following builds were ignored during install:
esbuild@0.20.2
puppeteer@22.8.0
# When nothing was skipped
$ aube ignored-builds
No ignored builds.
# Approve them for this project
$ aube approve-builds
";
#[derive(Debug, Args)]
pub struct IgnoredBuildsArgs {
/// Operate on globally-installed packages instead of the current project.
#[arg(short = 'g', long)]
pub global: bool,
}
pub async fn run(args: IgnoredBuildsArgs) -> miette::Result<()> {
if args.global {
return run_global();
}
let cwd = crate::dirs::project_root()?;
let ignored = collect_ignored(&cwd)?;
if ignored.is_empty() {
println!("No ignored builds.");
return Ok(());
}
println!("The following builds were ignored during install:");
for entry in &ignored {
println!(" {}@{}", entry.name, entry.version);
}
Ok(())
}
fn run_global() -> miette::Result<()> {
let layout = super::global::GlobalLayout::resolve()?;
let mut installs = super::global::scan_packages(&layout.pkg_dir);
installs.sort_by(|a, b| a.install_dir.cmp(&b.install_dir));
let mut printed = false;
let mut seen = std::collections::BTreeSet::new();
for info in installs {
if !seen.insert(info.install_dir.clone()) {
continue;
}
let ignored = collect_ignored(&info.install_dir)?;
if ignored.is_empty() {
continue;
}
if !printed {
println!("The following global builds were ignored during install:");
printed = true;
}
println!(
" {} ({})",
info.aliases.join(", "),
info.install_dir.display()
);
for entry in &ignored {
println!(" {}@{}", entry.name, entry.version);
}
}
if !printed {
println!("No ignored builds.");
}
Ok(())
}
/// One package whose lifecycle scripts were skipped because it was not
/// allowed by the current `BuildPolicy`. `name` is the pnpm package name,
/// `version` is the resolved version from the lockfile.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct IgnoredEntry {
pub name: String,
pub version: String,
}
impl std::cmp::Ord for IgnoredEntry {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.name
.cmp(&other.name)
.then_with(|| self.version.cmp(&other.version))
}
}
impl std::cmp::PartialOrd for IgnoredEntry {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
/// Load the lockfile and build policy for `project_dir`, then return the
/// sorted, deduplicated list of `(name, version)` pairs that declare a
/// dep-lifecycle hook and are not allowed by the policy.
///
/// Returns an empty list (not an error) if there is no lockfile yet —
/// callers print their own "nothing to do" message.
pub(super) fn collect_ignored(project_dir: &std::path::Path) -> miette::Result<Vec<IgnoredEntry>> {
let manifest = super::load_manifest(&project_dir.join("package.json"))?;
let graph = match aube_lockfile::parse_lockfile(project_dir, &manifest) {
Ok(g) => g,
Err(aube_lockfile::Error::NotFound(_)) => return Ok(Vec::new()),
Err(e) => return Err(miette::Report::new(e)).wrap_err("failed to parse lockfile"),
};
let workspace = aube_manifest::WorkspaceConfig::load(project_dir)
.into_diagnostic()
.wrap_err("failed to load workspace config")?;
let (policy, _warnings) =
super::install::build_policy_from_sources(&manifest, &workspace, false);
let store = super::open_store(project_dir)?;
let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
let mut out: Vec<IgnoredEntry> = Vec::new();
for pkg in graph.packages.values() {
if !seen.insert((pkg.name.clone(), pkg.version.clone())) {
continue;
}
// Match on registry_name, not pkg.name. Allowlist pins the
// real pkg name. npm: alias would sneak past otherwise. Same
// fix as every other policy.decide callsite.
if matches!(
policy.decide(pkg.registry_name(), &pkg.version),
aube_scripts::AllowDecision::Allow
) {
continue;
}
if !has_lifecycle_scripts(&store, &pkg.name, &pkg.version, pkg.integrity.as_deref()) {
continue;
}
out.push(IgnoredEntry {
name: pkg.name.clone(),
version: pkg.version.clone(),
});
}
out.sort();
Ok(out)
}
/// Read `<name>@<version>`'s stored `package.json` from the global store
/// index and return true when any dep-lifecycle script (preinstall /
/// install / postinstall) is declared — or when the package ships a
/// top-level `binding.gyp` with no install/preinstall script, which
/// means the install pipeline would have fallen back to the implicit
/// `node-gyp rebuild` default.
///
/// Missing / unreadable manifests conservatively return `false` — the
/// package might have scripts we can't see, but reporting them as
/// "ignored" would be noise since the install pipeline also skipped
/// them for the same reason.
fn has_lifecycle_scripts(
store: &aube_store::Store,
name: &str,
version: &str,
integrity: Option<&str>,
) -> bool {
// Cache lookup is integrity-keyed when available (prevents
// same-(name, version) entries from different sources colliding)
// and falls back to the plain (name, version) key when integrity
// is absent so proxy-served packages without `dist.integrity` are
// still classifiable.
let Some(index) = store.load_index(name, version, integrity) else {
return false;
};
let Some(stored) = index.get("package.json") else {
return false;
};
let Ok(content) = std::fs::read_to_string(&stored.store_path) else {
return false;
};
let Ok(manifest) = serde_json::from_str::<aube_manifest::PackageJson>(&content) else {
return false;
};
if aube_scripts::DEP_LIFECYCLE_HOOKS
.iter()
.any(|h| manifest.scripts.contains_key(h.script_name()))
{
return true;
}
// Delegate the implicit-rebuild gate to `aube-scripts` so this
// stays in lockstep with what the install pipeline actually runs.
// Presence comes from the store index here (the package isn't
// materialized yet at this point in the command), but the
// condition itself lives in exactly one place.
aube_scripts::implicit_install_script(&manifest, index.contains_key("binding.gyp")).is_some()
}