/
githubmirror
/
deno
Обзор
Документация
Войти
/
githubmirror
/
deno
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
cli/tools/pm/approve_scripts.rs
410 строк
11 KB
Bartek Iwańczuk
refactor(cli): replace single-method flag ext traits with free functions (#36491)
08 авг 2026, 02:53
Не верифицирован
08 авг 2026, 02:53
ce9bc92
Код
Авторство
О чём код?
// Copyright 2018-2026 the Deno authors. MIT license. use std::collections::HashSet; use std::fmt::Write as _; use std::sync::Arc; use console_static_text::TextItem; use deno_config::deno_json::AllowScriptsConfig; use deno_config::deno_json::AllowScriptsValueConfig; use deno_core::anyhow; use deno_core::anyhow::Context; use deno_core::anyhow::bail; use deno_core::error::AnyError; use deno_path_util::url_to_file_path; use deno_semver::jsr::JsrDepPackageReq; use deno_semver::jsr::JsrDepPackageReqParseError; use deno_semver::package::PackageKind; use deno_semver::package::PackageNv; use deno_semver::package::PackageReq; use deno_terminal::colors; use jsonc_parser::json; use super::CacheTopLevelDepsOptions; use crate::args::ApproveScriptsFlags; use crate::args::Flags; use crate::args::npm_system_info; use crate::factory::CliFactory; use crate::npm::CliNpmResolver; use crate::tools::pm::ConfigKind; use crate::tools::pm::ConfigUpdater; use crate::tools::pm::create_deno_json; use crate::tools::pm::interactive_picker; use crate::util::console::escape_terminal_control_chars; struct ScriptCandidate { req: PackageReq, specifier: String, scripts: Vec<String>, } pub async fn approve_scripts( flags: Arc<Flags>, approve_flags: ApproveScriptsFlags, ) -> Result<(), AnyError> { let mut factory = CliFactory::from_flags(flags.clone()); let mut options = factory.cli_options()?; if options.start_dir.member_or_root_deno_json().is_none() { factory = create_deno_json(&flags, options)?; options = factory.cli_options()?; } let deno_json = options.workspace().root_deno_json().ok_or_else(|| { anyhow::anyhow!("A deno.json file could not be found or created") })?; let deno_json_path = url_to_file_path(&deno_json.specifier)?; let mut config_updater = ConfigUpdater::new(ConfigKind::DenoJson, deno_json_path.clone())?; let allow_scripts_config = deno_json.to_allow_scripts_config()?; let (mut allow_list, mut deny_list) = match allow_scripts_config.allow { AllowScriptsValueConfig::All => { log::info!( "Lifecycle scripts are already allowed for all npm packages in the workspace.", ); return Ok(()); } AllowScriptsValueConfig::Limited(list) => (list, allow_scripts_config.deny), }; let mut existing_allowed: HashSet<PackageReq> = allow_list.iter().map(|req| req.req.clone()).collect(); let deny_reqs: Vec<PackageReq> = deny_list.iter().map(|req| req.req.clone()).collect(); let (approvals, denials) = if !approve_flags.packages.is_empty() { ( parse_user_packages(&approve_flags.packages, &mut existing_allowed)?, Vec::new(), ) } else { let npm_installer = factory.npm_installer().await?; npm_installer .ensure_top_level_package_json_install() .await?; let npm_resolver = factory.npm_resolver().await?; let candidates = find_script_candidates( npm_resolver, &npm_system_info(&flags.subcommand), &allow_list, &deny_reqs, )?; if candidates.is_empty() { log::info!("No npm packages with lifecycle scripts need approval."); return Ok(()); } let chosen = pick_candidates(&candidates, &mut existing_allowed)?; (chosen.approved, chosen.denied) }; if approvals.is_empty() && denials.is_empty() { log::info!("No new packages to approve."); return Ok(()); } for req in &approvals { allow_list.push(JsrDepPackageReq::npm(req.clone())); } for req in &denials { deny_list.push(JsrDepPackageReq::npm(req.clone())); } if !approvals.is_empty() { deny_list.retain(|entry| { !(entry.kind == PackageKind::Npm && approvals.contains(&entry.req)) }); } allow_list.sort_by_key(|a| a.to_string()); allow_list.dedup_by(|a, b| a.req == b.req && a.kind == b.kind); deny_list.sort_by_key(|a| a.to_string()); deny_list.dedup_by(|a, b| a.req == b.req && a.kind == b.kind); let updated_allow_scripts = AllowScriptsConfig { allow: AllowScriptsValueConfig::Limited(allow_list), deny: deny_list, }; let allow_scripts_value = allow_scripts_to_value(&updated_allow_scripts); config_updater.set_allow_scripts_value(allow_scripts_value); config_updater.commit()?; for req in denials { log::info!( "{} {}{}", colors::yellow("Denied"), colors::gray("npm:"), req ) } for req in approvals.iter() { log::info!( "{} {}{}", colors::green("Approved"), colors::gray("npm:"), req ); } super::npm_install_after_modification( flags, None, CacheTopLevelDepsOptions { lockfile_only: approve_flags.lockfile_only, additional_roots: vec![], }, ) .await?; for req in approvals { log::info!( "{} {}{}", colors::cyan("Ran build script"), colors::gray("npm:"), req ); } Ok(()) } fn parse_user_packages( packages: &[String], existing_allowed: &mut HashSet<PackageReq>, ) -> Result<Vec<PackageReq>, AnyError> { let mut additions = Vec::new(); for raw in packages { let req = parse_npm_package_req(raw) .with_context(|| format!("Failed to parse package: {}", raw))?; if existing_allowed.insert(req.clone()) { additions.push(req); } } Ok(additions) } fn find_script_candidates( npm_resolver: &CliNpmResolver, system_info: &deno_npm::NpmSystemInfo, allow_list: &[JsrDepPackageReq], deny_list: &[PackageReq], ) -> Result<Vec<ScriptCandidate>, AnyError> { let managed_resolver = npm_resolver.as_managed().with_context(|| { "Lifecycle script approval requires an npm resolution. Run `deno install` first to create one." })?; let snapshot = managed_resolver.resolution().snapshot(); let mut candidates = Vec::new(); let mut seen = HashSet::<PackageNv>::new(); for package in snapshot.all_system_packages(system_info) { if !package.has_scripts { continue; } if !seen.insert(package.id.nv.clone()) { continue; } if allow_list .iter() .any(|req| package_req_matches_nv(&req.req, &package.id.nv)) { continue; } if deny_list .iter() .any(|req| package_req_matches_nv(req, &package.id.nv)) { continue; } let specifier = format!("npm:{}@{}", package.id.nv.name, package.id.nv.version); let req = PackageReq::from_str(&format!( "{}@{}", package.id.nv.name, package.id.nv.version ))?; let mut scripts = package .extra .as_ref() .map(|extra| { let mut names = extra .scripts .keys() .map(|k| k.to_string()) .collect::<Vec<_>>(); names.sort(); names }) .unwrap_or_default(); scripts.dedup(); candidates.push(ScriptCandidate { req, specifier, scripts, }); } candidates.sort_by(|a, b| a.specifier.cmp(&b.specifier)); Ok(candidates) } #[derive(Default, Debug)] struct ChosenCandidates { approved: Vec<PackageReq>, denied: Vec<PackageReq>, } fn pick_candidates( candidates: &[ScriptCandidate], existing_allowed: &mut HashSet<PackageReq>, ) -> Result<ChosenCandidates, AnyError> { if candidates.is_empty() { return Ok(ChosenCandidates { denied: candidates.iter().map(|c| c.req.clone()).collect(), ..Default::default() }); } let selected = interactive_picker::select_items( "Select which packages to approve lifecycle scripts for (<space> to select, ↑/↓/j/k to navigate, a to select all, i to invert selection, enter to accept, <Ctrl-c> to cancel)", candidates, HashSet::new(), |_idx, is_selected, is_checked, candidate| { render_candidate(candidate, is_selected, is_checked) }, )?; let Some(selected) = selected else { return Ok(ChosenCandidates::default()); }; let mut approvals = Vec::with_capacity(selected.len()); let mut denials = Vec::with_capacity(candidates.len() - selected.len()); for (idx, candidate) in candidates.iter().enumerate() { if selected.contains(&idx) { if existing_allowed.insert(candidate.req.clone()) { approvals.push(candidate.req.clone()); } } else { denials.push(candidate.req.clone()); } } Ok(ChosenCandidates { approved: approvals, denied: denials, }) } fn allow_scripts_to_value( config: &AllowScriptsConfig, ) -> jsonc_parser::cst::CstInputValue { let deny: Vec<String> = config.deny.iter().map(|r| r.to_string()).collect(); match &config.allow { AllowScriptsValueConfig::All => { if deny.is_empty() { json!(true) } else { json!({ "allow": true, "deny": deny }) } } AllowScriptsValueConfig::Limited(reqs) => { let allow: Vec<String> = reqs.iter().map(|req| req.to_string()).collect(); if deny.is_empty() { json!(allow) } else { json!({ "allow": allow, "deny": deny }) } } } } fn parse_npm_package_req(text: &str) -> Result<PackageReq, AnyError> { let req = match JsrDepPackageReq::from_str_loose(text) { Ok(JsrDepPackageReq { kind: PackageKind::Jsr, .. }) => { bail!("Only npm packages are supported: {}", text); } Ok( req @ JsrDepPackageReq { kind: PackageKind::Npm, .. }, ) => req, Err(JsrDepPackageReqParseError::NotExpectedScheme(_)) if !text.contains(':') => { return parse_npm_package_req(&format!("npm:{text}")); } Err(e) => return Err(e.into()), }; if req.req.version_req.tag().is_some() { bail!("Tags are not supported in the allowScripts field: {}", text); } Ok(req.req) } fn package_req_matches_nv(req: &PackageReq, nv: &PackageNv) -> bool { req.name == nv.name && req.version_req.matches(&nv.version) } fn render_candidate( candidate: &ScriptCandidate, is_selected: bool, is_checked: bool, ) -> Result<TextItem<'static>, AnyError> { let mut line = String::new(); write!( &mut line, "{} {} {}", if is_selected { colors::intense_blue("❯").to_string() } else { " ".to_string() }, if is_checked { "●" } else { "○" }, candidate.specifier )?; if !candidate.scripts.is_empty() { let scripts = candidate.scripts.join(", "); let scripts = escape_terminal_control_chars(&scripts); write!( &mut line, " {}", colors::gray(format!("scripts: {scripts}")) )?; } Ok(TextItem::with_hanging_indent_owned(line, 2)) } #[cfg(test)] mod tests { use super::*; #[test] fn render_candidate_escapes_script_name_controls() { let candidate = ScriptCandidate { req: PackageReq::from_str("example@1").unwrap(), specifier: "npm:example@1.0.0".to_string(), scripts: vec![ "pre\x1b[2Jclear".to_string(), "\x1b]8;;https://example.com\x07link".to_string(), "\u{009b}31mcolor".to_string(), "\u{202e}txt".to_string(), ], }; let item = render_candidate(&candidate, false, false).unwrap(); let TextItem::HangingText { text, .. } = item else { panic!("expected hanging text"); }; assert!(text.contains(r"pre\u{1b}[2Jclear")); assert!(text.contains(r"\u{1b}]8;;https://example.com\u{7}link")); assert!(text.contains(r"\u{9b}31mcolor")); assert!(text.contains(r"\u{202e}txt")); assert!(!text.contains("pre\x1b[2Jclear")); assert!(!text.contains('\u{202e}')); } }