Skip to main content

bynk/
approve.rs

1//! `bynk approve` (packaging slice 5, #2296, decision D): review and approve
2//! the npm packages that fetched (git or registry) packages' adapters bring
3//! into the build, recording each approval in `bynk.lock`.
4//!
5//! An approval is per `[[npm]]` row — one npm package at one range (decision
6//! A). The compile refuses a reached adapter's unapproved requirement
7//! (`bynk.requires.unapproved`, decision B); this command is how a project
8//! clears it. It rewrites only `approved` fields and never resolves.
9
10use std::collections::{BTreeMap, BTreeSet, VecDeque};
11use std::io::{BufRead, IsTerminal, Write};
12use std::path::{Path, PathBuf};
13
14use bynk_project::lock::{LOCK_FILE, Lock, LockedNpm, lock_root, parse_lock};
15
16/// What `approve` should do.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum Mode {
19    /// Ask about each pending row (`bynk approve`); refused off a terminal.
20    Ask,
21    /// Approve the pending rows of this npm package.
22    Package(String),
23    /// Approve every pending row.
24    All,
25    /// List the rows; change nothing.
26    List,
27}
28
29/// `bynk approve` for the project holding `project`.
30pub fn run(project: &Path, mode: &Mode) -> Result<(), String> {
31    let (path, mut lock) = read(project)?;
32    let pending = pending_rows(&lock);
33    if *mode == Mode::List {
34        list(&lock, &pending);
35        return Ok(());
36    }
37    let chosen: Vec<usize> = match mode {
38        Mode::All => pending.clone(),
39        Mode::Package(name) => {
40            let rows: Vec<usize> = pending
41                .iter()
42                .copied()
43                .filter(|&i| lock.npm[i].package == *name)
44                .collect();
45            if rows.is_empty() {
46                return Err(if lock.npm.iter().any(|n| n.package == *name) {
47                    format!("the npm package `{name}` has nothing awaiting approval")
48                } else {
49                    format!("no dependency's adapter requires the npm package `{name}`")
50                });
51            }
52            for &i in &rows {
53                describe(&lock, &lock.npm[i]);
54            }
55            rows
56        }
57        Mode::Ask => {
58            if pending.is_empty() {
59                eprintln!("bynk: no npm packages await approval");
60                return Ok(());
61            }
62            if !std::io::stdin().is_terminal() {
63                return Err(
64                    "`bynk approve` asks on a terminal: name a package (`bynk approve <package>`), or use `--all`"
65                        .to_string(),
66                );
67            }
68            ask(&lock, &pending)
69        }
70        Mode::List => unreachable!(),
71    };
72    approve(&path, &mut lock, &chosen)
73}
74
75/// #2296: after `bynk add`, `fetch` or `update` wrote the lock with `pending`
76/// rows, ask about each on a terminal; otherwise say what awaits approval and
77/// how to approve it. Never fails the command (decision D).
78pub fn after_write(project: &Path, pending: &[LockedNpm]) {
79    if pending.is_empty() {
80        return;
81    }
82    let Ok((path, mut lock)) = read(project) else {
83        return;
84    };
85    let rows: Vec<usize> = pending_rows(&lock)
86        .into_iter()
87        .filter(|&i| {
88            pending
89                .iter()
90                .any(|p| p.package == lock.npm[i].package && p.range == lock.npm[i].range)
91        })
92        .collect();
93    if rows.is_empty() {
94        return;
95    }
96    if std::io::stdin().is_terminal() {
97        eprintln!(
98            "bynk: {} npm package{} from fetched packages await{} approval",
99            rows.len(),
100            if rows.len() == 1 { "" } else { "s" },
101            if rows.len() == 1 { "s" } else { "" }
102        );
103        let chosen = ask(&lock, &rows);
104        if let Err(e) = approve(&path, &mut lock, &chosen) {
105            eprintln!("bynk: {e}");
106        }
107        return;
108    }
109    eprintln!("bynk: npm packages awaiting approval — a build reaching them fails until approved:");
110    for &i in &rows {
111        describe(&lock, &lock.npm[i]);
112    }
113    eprintln!(
114        "bynk: review them, then run `bynk approve <package>` (or `bynk approve` on a terminal)"
115    );
116}
117
118fn read(project: &Path) -> Result<(PathBuf, Lock), String> {
119    let root = lock_root(project);
120    let path = root.join(LOCK_FILE);
121    let text = std::fs::read_to_string(&path).map_err(|_| {
122        format!(
123            "there is no `{}` here — run `bynk fetch` first",
124            path.display()
125        )
126    })?;
127    let lock = parse_lock(&text).map_err(|e| format!("`{}`: {}", path.display(), e.0))?;
128    Ok((path, lock))
129}
130
131/// The rows awaiting approval: unapproved, and required by some fetched
132/// (not path) package's adapter.
133fn pending_rows(lock: &Lock) -> Vec<usize> {
134    let fetched = |by: &str| {
135        let name = by.split(':').next().unwrap_or_default();
136        lock.package(name)
137            .is_none_or(|p| !p.source.starts_with("path+"))
138    };
139    lock.npm
140        .iter()
141        .enumerate()
142        .filter(|(_, n)| !n.approved && n.required_by.iter().any(|by| fetched(by)))
143        .map(|(i, _)| i)
144        .collect()
145}
146
147fn list(lock: &Lock, pending: &[usize]) {
148    if lock.npm.is_empty() {
149        eprintln!("bynk: no dependency's adapter requires an npm package");
150        return;
151    }
152    for (i, row) in lock.npm.iter().enumerate() {
153        let state = if row.approved {
154            "approved"
155        } else if pending.contains(&i) {
156            "pending"
157        } else {
158            "own"
159        };
160        println!(
161            "{state:<8} {} {}  (required by {})",
162            row.package,
163            row.range,
164            row.required_by.join(", ")
165        );
166    }
167}
168
169/// Print one row: the package, range, who requires it and through what.
170fn describe(lock: &Lock, row: &LockedNpm) {
171    eprintln!("  {} {}", row.package, row.range);
172    for by in &row.required_by {
173        let package = by.split(':').next().unwrap_or_default();
174        let mut chain = vec!["this project".to_string()];
175        chain.extend(chain_to(lock, package));
176        chain.push(by.clone());
177        eprintln!("    required through {}", chain.join(" → "));
178    }
179    eprintln!("    https://www.npmjs.com/package/{}", row.package);
180}
181
182/// Ask `[y/N]` about each row; the rows answered yes.
183fn ask(lock: &Lock, rows: &[usize]) -> Vec<usize> {
184    let stdin = std::io::stdin();
185    let mut lines = stdin.lock().lines();
186    let mut chosen = Vec::new();
187    for &i in rows {
188        describe(lock, &lock.npm[i]);
189        eprint!(
190            "Approve `{}` {}? [y/N] ",
191            lock.npm[i].package, lock.npm[i].range
192        );
193        let _ = std::io::stderr().flush();
194        let answer = lines.next().and_then(Result::ok).unwrap_or_default();
195        if matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
196            chosen.push(i);
197        }
198    }
199    chosen
200}
201
202fn approve(path: &Path, lock: &mut Lock, rows: &[usize]) -> Result<(), String> {
203    if rows.is_empty() {
204        eprintln!("bynk: nothing approved");
205        return Ok(());
206    }
207    for &i in rows {
208        lock.npm[i].approved = true;
209    }
210    crate::fetch::write_atomically(path, &lock.render())
211        .map_err(|e| format!("could not write `{}`: {e}", path.display()))?;
212    for &i in rows {
213        eprintln!(
214            "bynk: approved npm package `{}` {}",
215            lock.npm[i].package, lock.npm[i].range
216        );
217    }
218    Ok(())
219}
220
221/// A chain of locked packages from the project to `target`. The lock doesn't
222/// record the project's own aliases, so its direct dependencies are taken to
223/// be the locked packages no other one depends on: a direct dependency that is
224/// also reached transitively shows through its longer path. The compile's
225/// diagnostic (`PackageGraph::chain_to`) knows the aliases and is exact.
226fn chain_to(lock: &Lock, target: &str) -> Vec<String> {
227    let depended: BTreeSet<&str> = lock
228        .packages
229        .iter()
230        .flat_map(|p| p.dependencies.iter().map(String::as_str))
231        .collect();
232    let mut parent: BTreeMap<&str, Option<&str>> = BTreeMap::new();
233    let mut queue = VecDeque::new();
234    for p in &lock.packages {
235        if !depended.contains(p.name.as_str()) {
236            parent.insert(&p.name, None);
237            queue.push_back(p.name.as_str());
238        }
239    }
240    while let Some(name) = queue.pop_front() {
241        let Some(p) = lock.package(name) else {
242            continue;
243        };
244        for d in &p.dependencies {
245            if !parent.contains_key(d.as_str()) {
246                parent.insert(d, Some(name));
247                queue.push_back(d);
248            }
249        }
250    }
251    let mut chain = Vec::new();
252    let mut at = parent.contains_key(target).then_some(target);
253    while let Some(name) = at {
254        chain.push(name.to_string());
255        at = parent.get(name).copied().flatten();
256        if chain.len() > lock.packages.len() {
257            break;
258        }
259    }
260    if chain.is_empty() {
261        chain.push(target.to_string());
262    }
263    chain.reverse();
264    chain
265}