diff --git a/src/cmd/list.rs b/src/cmd/list.rs index 948aa51..31b53f9 100644 --- a/src/cmd/list.rs +++ b/src/cmd/list.rs @@ -1,6 +1,6 @@ use crate::nu::paths::NuPaths; use crate::nupm_compat::schema::NUPM_IMPORT_ORIGIN; -use crate::state::lockfile::Lockfile; +use crate::state::lockfile::{Lockfile, BUNDLED_NU_ORIGIN}; use anyhow::Result; use std::io::Write; use std::path::Path; @@ -36,6 +36,8 @@ fn execute_to(root: &Path, out: &mut dyn Write) -> Result<()> { }; let origin_tag = if entry.origin.as_deref() == Some(NUPM_IMPORT_ORIGIN) { " (nupm import)" + } else if entry.origin.as_deref() == Some(BUNDLED_NU_ORIGIN) { + " (bundled with Nu)" } else { "" }; diff --git a/src/cmd/nu_pin_offer.rs b/src/cmd/nu_pin_offer.rs index afe8ed6..81149ff 100644 --- a/src/cmd/nu_pin_offer.rs +++ b/src/cmd/nu_pin_offer.rs @@ -86,7 +86,7 @@ where pub fn install_pinned_nu_and_refresh(root: &Path, pin: &str) -> Result<()> { setup::execute_nu( - &NuSetupArgs::install(Some(pin.to_string()), true, false, true), + &NuSetupArgs::install(Some(pin.to_string()), true, false, true, false), root, ) .with_context(|| format!("Failed to install managed Nu {pin}"))?; diff --git a/src/cmd/remove.rs b/src/cmd/remove.rs index e75658e..410d779 100644 --- a/src/cmd/remove.rs +++ b/src/cmd/remove.rs @@ -4,7 +4,7 @@ use std::io::IsTerminal; use std::path::Path; use crate::state::lifecycle_journal::{LifecycleOp, LifecycleStage, PendingLifecycle}; -use crate::state::lockfile::{Lockfile, LockfileEntry}; +use crate::state::lockfile::{Lockfile, LockfileEntry, BUNDLED_NU_ORIGIN}; use crate::state::nupm_import::NupmImportsFile; use crate::state::snapshot::{create_snapshot, SnapshotReason, SnapshotTrigger}; use crate::util::fs_safety::acquire_mutation_lock; @@ -55,6 +55,7 @@ fn execute_with_tty(args: &RemoveArgs, root: &Path, is_tty: bool) -> Result<()> }; ensure_plugin_not_active(&entry, &args.package)?; + ensure_not_bundled_plugin(&entry, &args.package)?; if !args.force && entry.module_activation.is_some() { bail!( "Package '{}' is currently active as a module. \ @@ -89,6 +90,7 @@ fn execute_with_tty(args: &RemoveArgs, root: &Path, is_tty: bool) -> Result<()> ), }; ensure_plugin_not_active(&entry, &args.package)?; + ensure_not_bundled_plugin(&entry, &args.package)?; if !args.force && entry.module_activation.is_some() { bail!( "Package '{}' is currently active as a module. \ @@ -187,6 +189,17 @@ fn ensure_plugin_not_active(entry: &LockfileEntry, pkg_id: &str) -> Result<()> { Ok(()) } +/// Refuse remove when the entry is a bundled-Nu plugin whose payload directory +/// is shared with the managed Nu install (data-loss guard; `remove_dir_all` +/// would wipe the whole `tools/nushell//` tree). `--force` does not +/// bypass this check. +fn ensure_not_bundled_plugin(entry: &LockfileEntry, pkg_id: &str) -> Result<()> { + if entry.origin.as_deref() == Some(BUNDLED_NU_ORIGIN) { + bail!("{}", hints::bundled_plugin_remove_gated(pkg_id)); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -275,6 +288,75 @@ mod tests { assert!(msg.contains("Issue #22")); } + #[test] + fn ensure_not_bundled_plugin_refuses_bundled_origin() { + let entry = LockfileEntry { + origin: Some(BUNDLED_NU_ORIGIN.to_string()), + ..base_entry() + }; + let err = ensure_not_bundled_plugin(&entry, "nushell/polars").unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("nushell/polars")); + assert!(msg.contains("bundled Nushell plugin")); + assert!(msg.contains("numan setup nu remove")); + } + + #[test] + fn ensure_not_bundled_plugin_allows_registry_origin() { + let entry = LockfileEntry { + origin: Some("registry:official".to_string()), + ..base_entry() + }; + ensure_not_bundled_plugin(&entry, "owner/pkg").unwrap(); + } + + #[test] + fn execute_refuses_bundled_plugin_without_touching_payload() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + + // A bundled plugin entry shares the versioned Nu payload directory with + // the nu binary itself and every other bundled plugin. + let version_dir = root.join("tools/nushell/0.114.0"); + std::fs::create_dir_all(&version_dir).unwrap(); + std::fs::write(version_dir.join("nu"), b"fake nu binary").unwrap(); + std::fs::write(version_dir.join("nu_plugin_polars"), b"fake polars").unwrap(); + + let mut lockfile = Lockfile::empty(); + let mut entry = base_entry(); + entry.version = "0.114.0".to_string(); + entry.executable_path = Some("nu_plugin_polars".to_string()); + entry.payload_path = "tools/nushell/0.114.0".to_string(); + entry.origin = Some(BUNDLED_NU_ORIGIN.to_string()); + lockfile + .packages + .insert("nushell/polars".to_string(), entry); + lockfile.save(root).unwrap(); + + // Even --force must not bypass the bundled guard: remove_dir_all on the + // shared payload would destroy the entire managed Nu install. + let err = execute_with_tty( + &RemoveArgs { + package: "nushell/polars".to_string(), + yes: true, + force: true, + }, + root, + false, + ) + .unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("bundled Nushell plugin")); + assert!(msg.contains("numan setup nu remove")); + + // The shared payload (nu binary + bundled plugin) and lockfile entry + // must be untouched. + assert!(version_dir.join("nu").is_file()); + assert!(version_dir.join("nu_plugin_polars").is_file()); + let reloaded = Lockfile::load(root).unwrap(); + assert!(reloaded.packages.contains_key("nushell/polars")); + } + #[test] fn refuse_active_plugin_without_force() { let entry = LockfileEntry { diff --git a/src/cmd/setup.rs b/src/cmd/setup.rs index e27ae5f..954e988 100644 --- a/src/cmd/setup.rs +++ b/src/cmd/setup.rs @@ -81,6 +81,10 @@ pub struct NuSetupArgs { #[arg(long)] pub yes: bool, + /// Extract only the nu binary, skipping bundled plugins (polars, etc.) + #[arg(long)] + pub minimal: bool, + // COMPAT: remove in v0.3.0 — hidden backward-compat flags #[arg(long, hide = true)] pub remove: bool, @@ -117,13 +121,20 @@ pub enum NuAction { impl NuSetupArgs { /// Construct args for installing a managed Nu (latest or pinned). - pub fn install(version: Option, force: bool, skip_path: bool, yes: bool) -> Self { + pub fn install( + version: Option, + force: bool, + skip_path: bool, + yes: bool, + minimal: bool, + ) -> Self { Self { action: None, version, force, skip_path, yes, + minimal, remove: false, use_path: false, use_existing: None, @@ -144,6 +155,7 @@ impl NuSetupArgs { force: false, skip_path: false, yes, + minimal: false, remove: false, use_path: false, use_existing: None, @@ -158,6 +170,7 @@ impl NuSetupArgs { force: false, skip_path: false, yes, + minimal: false, remove: false, use_path: false, use_existing: None, @@ -172,6 +185,7 @@ impl NuSetupArgs { force: false, skip_path, yes, + minimal: false, remove: false, use_path: false, use_existing: None, @@ -186,6 +200,7 @@ impl NuSetupArgs { force: false, skip_path: false, yes, + minimal: false, remove: false, use_path: false, use_existing: None, @@ -308,6 +323,7 @@ fn execute_nu_impl_locked(args: &NuSetupArgs, root: &Path) -> Result<()> { force: args.force, skip_path: args.skip_path, version: args.version.clone(), + minimal: args.minimal, caller_consented_destructive: false, is_tty: None, }; @@ -451,6 +467,7 @@ fn execute_use_path(yes: bool, root: &Path, force: bool, opts: ExecuteUseOpts<'_ force: false, skip_path: false, version: None, + minimal: false, // Hoist consent so register_existing_nu's inner PATH prompt is // suppressed -- only valid because the merged prompt above // collected consent for both the delete AND the PATH add. @@ -545,6 +562,7 @@ fn execute_use_existing( force: false, skip_path: false, version: None, + minimal: false, // Hoist consent so register_existing_nu's inner PATH prompt is // suppressed -- only valid because the merged prompt above // collected consent for both the delete AND the PATH add. @@ -1255,6 +1273,7 @@ mod tests { force: false, skip_path: false, yes: true, + minimal: false, remove: true, use_path: false, use_existing: None, diff --git a/src/nu/bootstrap.rs b/src/nu/bootstrap.rs index 491490c..082c943 100644 --- a/src/nu/bootstrap.rs +++ b/src/nu/bootstrap.rs @@ -11,6 +11,7 @@ use crate::install::download::download_file; use crate::install::extract::{extract_archive, ArchiveFormat, ExtractConfig}; use crate::nu::paths::validate_nushell_binary; use crate::nu::version_manager; +use crate::state::lockfile::{Lockfile, LockfileEntry, BUNDLED_NU_ORIGIN}; use crate::state::snapshot::{create_snapshot, SnapshotReason, SnapshotTrigger}; #[cfg(unix)] use crate::util::atomic::write_bytes_atomic; @@ -71,11 +72,15 @@ fn nu_binary_name() -> &'static str { } } -fn nu_release_extract_config() -> ExtractConfig { +fn nu_release_extract_config(minimal: bool) -> ExtractConfig { ExtractConfig { - // Official releases ship nu plus large bundled plugins (e.g. polars). - // Managed install only needs the shell binary. - include: Some(vec![format!("**/{}", nu_binary_name())]), + // When minimal is true, filter to just the shell binary (old behavior). + // When minimal is false, extract everything (nu + bundled plugins). + include: if minimal { + Some(vec![format!("**/{}", nu_binary_name())]) + } else { + None + }, max_uncompressed_bytes: Some(NU_RELEASE_MAX_UNCOMPRESSED_BYTES), ..ExtractConfig::default() } @@ -216,7 +221,12 @@ fn make_executable(_path: &Path) -> Result<()> { Ok(()) } -pub fn install_from_archive(archive_path: &Path, root: &Path, version: &str) -> Result { +pub fn install_from_archive( + archive_path: &Path, + root: &Path, + version: &str, + minimal: bool, +) -> Result { let format = archive_format_for_url( archive_path .file_name() @@ -234,7 +244,7 @@ pub fn install_from_archive(archive_path: &Path, root: &Path, version: &str) -> extract_archive( archive_path, &extract_root, - &nu_release_extract_config(), + &nu_release_extract_config(minimal), format, ) .with_context(|| format!("Failed to extract '{}'", archive_path.display()))?; @@ -258,6 +268,7 @@ pub fn install_from_archive(archive_path: &Path, root: &Path, version: &str) -> })?; let dest = version_manager::version_binary(root, &normalized); + // Copy the nu binary std::fs::copy(&source, &dest).with_context(|| { format!( "Failed to copy Nushell binary from '{}' to '{}'", @@ -266,6 +277,14 @@ pub fn install_from_archive(archive_path: &Path, root: &Path, version: &str) -> ) })?; make_executable(&dest)?; + + // When not in minimal mode, copy all other extracted files (plugins etc.) + // into the versioned directory alongside the nu binary. + if !minimal { + let extract_subdir = source.parent().unwrap_or(&extract_root); + copy_extracted_files(extract_subdir, &dest_dir, &source)?; + } + // Keep the legacy VERSION marker for backwards compat with tooling that // greps for it, but under the versioned dir so it never shadows a sibling // version's marker. Write the normalized version so `detect_legacy_version` @@ -280,6 +299,43 @@ pub fn install_from_archive(archive_path: &Path, root: &Path, version: &str) -> Ok(dest) } +/// Copy extracted `nu_plugin_*` files from `src_dir` into `dest_dir`, skipping +/// `skip_file` (which has already been copied as the nu binary). Only files +/// whose name starts with `nu_plugin_` are copied; other archive contents +/// (README, LICENSE, etc.) are intentionally excluded to keep the version +/// directory clean. +fn copy_extracted_files(src_dir: &Path, dest_dir: &Path, skip_file: &Path) -> Result<()> { + let entries = std::fs::read_dir(src_dir) + .with_context(|| format!("Failed to read extracted directory '{}'", src_dir.display()))?; + for entry in entries { + let entry = entry?; + let path = entry.path(); + // Skip the nu binary (already copied) and directories + if path == skip_file || !path.is_file() { + continue; + } + let file_name = match path.file_name() { + Some(n) => n, + None => continue, + }; + // Only copy plugin binaries (nu_plugin_*); skip non-plugin files + // like README.txt, LICENSE, etc. + if !file_name.to_string_lossy().starts_with("nu_plugin_") { + continue; + } + let dest_path = dest_dir.join(file_name); + std::fs::copy(&path, &dest_path).with_context(|| { + format!( + "Failed to copy '{}' to '{}'", + path.display(), + dest_path.display() + ) + })?; + make_executable(&dest_path)?; + } + Ok(()) +} + struct ExtractCleanup(PathBuf); impl Drop for ExtractCleanup { @@ -288,6 +344,129 @@ impl Drop for ExtractCleanup { } } +/// Format current timestamp as a zero-padded Unix-seconds string (matches the +/// install-transaction lockfile timestamp shape). +fn now_timestamp() -> String { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + format!("{secs:016}") +} + +/// Scan the installed version directory for `nu_plugin_*` binaries bundled +/// with the official Nushell release and record them in the lockfile with the +/// `bundled:nu` origin. These plugins are already on disk, so no registry +/// install flow is needed; they become discoverable and activatable via +/// `numan activate`. Not auto-activated. +/// +/// The version directory (`tools/nushell//`) is scanned; each +/// `nu_plugin_*` file is hashed and written as a `type = "plugin"`, +/// `source = "binary"` lockfile entry keyed as `nushell/`. +fn discover_bundled_plugins(root: &Path, version_dir: &Path, nu_version: &str) -> Result<()> { + let entries = match std::fs::read_dir(version_dir) { + Ok(e) => e, + // No directory means nothing to discover (e.g. minimal install path). + Err(_) => return Ok(()), + }; + + let mut discovered: Vec<(String, LockfileEntry)> = Vec::new(); + let payload_path = version_dir + .strip_prefix(root) + .with_context(|| { + format!( + "Version directory '{}' is not under root '{}'", + version_dir.display(), + root.display() + ) + })? + .to_string_lossy() + .replace('\\', "/"); + + for entry in entries { + let entry = entry?; + let name = entry.file_name().to_string_lossy().to_string(); + if !name.starts_with("nu_plugin_") { + continue; + } + if !entry.file_type()?.is_file() { + continue; + } + + // Strip a platform suffix (`.exe` on Windows) then the `nu_plugin_` + // prefix to derive the plugin short name. + let stripped = name.strip_suffix(".exe").unwrap_or(&name); + let plugin_name = match stripped.strip_prefix("nu_plugin_") { + Some(n) if !n.is_empty() => n, + _ => continue, + }; + let package_id = format!("nushell/{plugin_name}"); + + let bytes = std::fs::read(entry.path()).with_context(|| { + format!( + "Failed to read bundled plugin binary '{}'", + entry.path().display() + ) + })?; + let sha = integrity::compute_sha256(&bytes); + + let lockfile_entry = LockfileEntry { + version: nu_version.to_string(), + package_type: "plugin".to_string(), + source: "binary".to_string(), + target: None, + artifact_url: None, + artifact_sha256: None, + executable_path: Some(name.clone()), + archive_root: None, + include: None, + entry: None, + installed_at: now_timestamp(), + nu_version_at_install: Some(nu_version.to_string()), + activation: None, + registry_url: None, + registry_revision: None, + index_sha256: None, + signing_key_fingerprint: None, + git_url: None, + git_rev: None, + cargo_name: None, + cargo_lock_sha256: None, + built_sha256: None, + payload_path: payload_path.clone(), + revision_id: None, + payload_sha256: None, + executable_sha256: Some(sha), + selection_reason: None, + origin: Some(BUNDLED_NU_ORIGIN.to_string()), + module_activation: None, + module_import_mode: None, + locked_dependencies: std::collections::BTreeMap::new(), + }; + discovered.push((package_id, lockfile_entry)); + } + + if discovered.is_empty() { + return Ok(()); + } + + let mut lockfile = Lockfile::load(root)?; + for (package_id, entry) in discovered { + // Skip entries that already exist with a non-bundled origin. + // This respects a user's explicit registry install over automatic + // bundled extraction (e.g. "nushell/polars" installed from the + // registry should not be silently overwritten). + if let Some(existing) = lockfile.packages.get(&package_id) { + if existing.origin.as_deref() != Some(BUNDLED_NU_ORIGIN) { + continue; + } + } + lockfile.packages.insert(package_id, entry); + } + lockfile.save(root)?; + Ok(()) +} + fn verify_downloaded_archive(path: &Path, asset: &GitHubAsset) -> Result<()> { let bytes = std::fs::read(path) .with_context(|| format!("Failed to read downloaded archive '{}'", path.display()))?; @@ -316,16 +495,26 @@ fn verify_downloaded_archive(path: &Path, asset: &GitHubAsset) -> Result<()> { Ok(()) } -pub fn install_latest(root: &Path, platform: &Platform) -> Result { - install_release(root, platform, None) +pub fn install_latest(root: &Path, platform: &Platform, minimal: bool) -> Result { + install_release(root, platform, None, minimal) } /// Download and install a specific Nushell release tag (e.g. `0.113.1`). -pub fn install_version(root: &Path, platform: &Platform, version: &str) -> Result { - install_release(root, platform, Some(version)) +pub fn install_version( + root: &Path, + platform: &Platform, + version: &str, + minimal: bool, +) -> Result { + install_release(root, platform, Some(version), minimal) } -fn install_release(root: &Path, platform: &Platform, version: Option<&str>) -> Result { +fn install_release( + root: &Path, + platform: &Platform, + version: Option<&str>, + minimal: bool, +) -> Result { let client = reqwest::blocking::Client::builder() .timeout(std::time::Duration::from_secs(300)) .user_agent(USER_AGENT) @@ -335,7 +524,7 @@ fn install_release(root: &Path, platform: &Platform, version: Option<&str>) -> R Some(v) => fetch_release_by_tag(&client, v)?, None => fetch_latest_release(&client)?, }; - install_from_github_release(root, platform, &release) + install_from_github_release(root, platform, &release, minimal) } /// Install from an already-fetched GitHub release (avoids a second API call @@ -344,6 +533,7 @@ fn install_from_github_release( root: &Path, platform: &Platform, release: &GitHubRelease, + minimal: bool, ) -> Result { let asset = select_release_asset(release, platform)?; @@ -355,7 +545,7 @@ fn install_from_github_release( download_file(&asset.browser_download_url, &archive_path)?; verify_downloaded_archive(&archive_path, asset)?; - let installed = install_from_archive(&archive_path, root, &release.tag_name)?; + let installed = install_from_archive(&archive_path, root, &release.tag_name, minimal)?; validate_nushell_binary(&installed).with_context(|| { format!( "Installed Nushell binary at '{}' failed validation", @@ -786,6 +976,9 @@ pub struct NuSetupOptions { pub skip_path: bool, /// When set, download this release tag instead of latest. pub version: Option, + /// When `true`, extract only the `nu` binary from the release archive, + /// skipping bundled plugins. Default `false` extracts everything. + pub minimal: bool, /// When `true`, the caller has already collected destructive-step consent /// (e.g. `cmd::setup::execute_use_path` / `execute_use_existing` already /// prompted for both the managed-tree deletion and the PATH add). The @@ -807,8 +1000,9 @@ pub fn execute_nu_setup( ) -> Result { if let Some(ref version) = options.version { let version = version.clone(); + let minimal = options.minimal; return execute_nu_setup_with_installer(root, platform, options, move |r, p| { - install_version(r, p, &version) + install_version(r, p, &version, minimal) }); } // Unpinned (latest) flow: @@ -834,8 +1028,9 @@ pub fn execute_nu_setup( version: Some(tag), ..options.clone() }; + let minimal = options.minimal; execute_nu_setup_with_installer(root, platform, &pinned_opts, move |r, p| { - install_from_github_release(r, p, &release) + install_from_github_release(r, p, &release, minimal) }) } @@ -975,6 +1170,12 @@ where normalized ) })?; + + // Discover bundled plugins when full extraction mode was used. + if !options.minimal { + let version_dir = version_manager::version_install_dir(root, normalized); + discover_bundled_plugins(root, &version_dir, normalized)?; + } } // With the versioned layout the binary lives at @@ -1166,7 +1367,7 @@ mod tests { zip.finish().unwrap(); } - let installed = install_from_archive(&zip_path, root, "0.0.0-test").unwrap(); + let installed = install_from_archive(&zip_path, root, "0.0.0-test", false).unwrap(); // Installs land in the VERSIONED layout, never the legacy // single-binary path (which is migration-only now). assert_eq!( @@ -1275,7 +1476,8 @@ mod tests { NU_RELEASE_MAX_UNCOMPRESSED_BYTES > 279 * 1024 * 1024, "cap must clear known official Nu release sizes" ); - let cfg = nu_release_extract_config(); + // minimal=true preserves the old filter behavior + let cfg = nu_release_extract_config(true); assert_eq!( cfg.max_uncompressed_bytes, Some(NU_RELEASE_MAX_UNCOMPRESSED_BYTES) @@ -1284,10 +1486,17 @@ mod tests { cfg.include.as_ref().unwrap(), &vec![format!("**/{}", nu_binary_name())] ); + // minimal=false extracts everything + let cfg_full = nu_release_extract_config(false); + assert_eq!( + cfg_full.max_uncompressed_bytes, + Some(NU_RELEASE_MAX_UNCOMPRESSED_BYTES) + ); + assert!(cfg_full.include.is_none()); } #[test] - fn install_from_archive_skips_bundled_plugin_payloads() { + fn install_from_archive_minimal_skips_bundled_plugin_payloads() { let dir = TempDir::new().unwrap(); let root = dir.path(); let zip_path = root.join("nu-with-plugins.zip"); @@ -1326,11 +1535,320 @@ mod tests { "bundled plugin must not be written to disk" ); - let installed = install_from_archive(&zip_path, root, "0.0.0-plugins").unwrap(); + // With minimal=true, install_from_archive only extracts the nu binary + let installed = install_from_archive(&zip_path, root, "0.0.0-plugins", true).unwrap(); assert_eq!( std::fs::read(&installed).unwrap(), b"fake nu binary".as_slice() ); + // Plugin must NOT be present in the version directory + let version_dir = version_manager::version_install_dir(root, "0.0.0-plugins"); + assert!( + !version_dir.join("nu_plugin_polars").exists(), + "minimal install must not extract bundled plugin" + ); + } + + #[test] + fn install_from_archive_full_extracts_bundled_plugins() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + let zip_path = root.join("nu-with-plugins.zip"); + + let plugin_content = vec![0xCAu8; 1024]; + { + let file = std::fs::File::create(&zip_path).unwrap(); + let mut zip = ZipWriter::new(file); + let options = SimpleFileOptions::default(); + let nu_inner = format!("nu-0.0.0-test/{}", nu_binary_name()); + zip.start_file(&nu_inner, options).unwrap(); + zip.write_all(b"fake nu binary").unwrap(); + zip.start_file("nu-0.0.0-test/nu_plugin_polars", options) + .unwrap(); + zip.write_all(&plugin_content).unwrap(); + zip.start_file("nu-0.0.0-test/nu_plugin_formats", options) + .unwrap(); + zip.write_all(b"formats content").unwrap(); + zip.finish().unwrap(); + } + + // With minimal=false (default), all files are extracted + let installed = install_from_archive(&zip_path, root, "0.0.0-full", false).unwrap(); + assert_eq!( + std::fs::read(&installed).unwrap(), + b"fake nu binary".as_slice() + ); + // Plugins must be present in the version directory + let version_dir = version_manager::version_install_dir(root, "0.0.0-full"); + assert!( + version_dir.join("nu_plugin_polars").exists(), + "full install must extract bundled plugin polars" + ); + assert!( + version_dir.join("nu_plugin_formats").exists(), + "full install must extract bundled plugin formats" + ); + assert_eq!( + std::fs::read(version_dir.join("nu_plugin_polars")).unwrap(), + plugin_content + ); + } + + #[test] + fn install_from_archive_full_skips_non_plugin_files() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + let zip_path = root.join("nu-with-extras.zip"); + + { + let file = std::fs::File::create(&zip_path).unwrap(); + let mut zip = ZipWriter::new(file); + let options = SimpleFileOptions::default(); + let nu_inner = format!("nu-0.0.0-test/{}", nu_binary_name()); + zip.start_file(&nu_inner, options).unwrap(); + zip.write_all(b"fake nu binary").unwrap(); + zip.start_file("nu-0.0.0-test/nu_plugin_polars", options) + .unwrap(); + zip.write_all(b"polars binary").unwrap(); + zip.start_file("nu-0.0.0-test/README.txt", options).unwrap(); + zip.write_all(b"readme content").unwrap(); + zip.start_file("nu-0.0.0-test/LICENSE", options).unwrap(); + zip.write_all(b"license content").unwrap(); + zip.finish().unwrap(); + } + + let installed = install_from_archive(&zip_path, root, "0.0.0-filter", false).unwrap(); + assert!(installed.is_file()); + + let version_dir = version_manager::version_install_dir(root, "0.0.0-filter"); + // Plugin must be present + assert!( + version_dir.join("nu_plugin_polars").exists(), + "plugin binary must be copied" + ); + // Non-plugin files must NOT be present + assert!( + !version_dir.join("README.txt").exists(), + "README.txt must not be copied to version directory" + ); + assert!( + !version_dir.join("LICENSE").exists(), + "LICENSE must not be copied to version directory" + ); + } + + #[test] + fn discover_bundled_plugins_writes_lockfile_entries() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + let zip_path = root.join("nu-with-plugins.zip"); + + let plugin_content = b"fake polars binary"; + { + let file = std::fs::File::create(&zip_path).unwrap(); + let mut zip = ZipWriter::new(file); + let options = SimpleFileOptions::default(); + let nu_inner = format!("nu-0.0.0-test/{}", nu_binary_name()); + zip.start_file(&nu_inner, options).unwrap(); + zip.write_all(b"fake nu binary").unwrap(); + zip.start_file("nu-0.0.0-test/nu_plugin_polars", options) + .unwrap(); + zip.write_all(plugin_content).unwrap(); + zip.start_file("nu-0.0.0-test/nu_plugin_query", options) + .unwrap(); + zip.write_all(b"fake query binary").unwrap(); + zip.finish().unwrap(); + } + + // Full extraction + install_from_archive(&zip_path, root, "0.114.0", false).unwrap(); + + // Run discovery + let version_dir = version_manager::version_install_dir(root, "0.114.0"); + discover_bundled_plugins(root, &version_dir, "0.114.0").unwrap(); + + // Verify lockfile entries + let lockfile = Lockfile::load(root).unwrap(); + let polars = lockfile + .packages + .get("nushell/polars") + .expect("polars entry"); + assert_eq!(polars.package_type, "plugin"); + assert_eq!(polars.source, "binary"); + assert_eq!(polars.origin.as_deref(), Some(BUNDLED_NU_ORIGIN)); + assert_eq!(polars.executable_path.as_deref(), Some("nu_plugin_polars")); + assert_eq!(polars.payload_path, "tools/nushell/0.114.0"); + assert_eq!(polars.version, "0.114.0"); + assert!(polars.executable_sha256.is_some()); + let expected_sha = integrity::compute_sha256(plugin_content); + assert_eq!( + polars.executable_sha256.as_deref(), + Some(expected_sha.as_str()) + ); + + let query = lockfile.packages.get("nushell/query").expect("query entry"); + assert_eq!(query.package_type, "plugin"); + assert_eq!(query.source, "binary"); + assert_eq!(query.origin.as_deref(), Some(BUNDLED_NU_ORIGIN)); + assert_eq!(query.executable_path.as_deref(), Some("nu_plugin_query")); + } + + #[test] + fn discover_bundled_plugins_skips_existing_registry_entry() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + + // Pre-populate a lockfile entry with a registry origin for nushell/polars + let mut lockfile = Lockfile::load(root).unwrap(); + lockfile.packages.insert( + "nushell/polars".to_string(), + LockfileEntry { + version: "0.114.0".to_string(), + package_type: "plugin".to_string(), + source: "binary".to_string(), + target: None, + artifact_url: Some("https://registry.example.com/polars.tar.gz".to_string()), + artifact_sha256: Some("registry_sha256_value".to_string()), + executable_path: Some("nu_plugin_polars".to_string()), + archive_root: None, + include: None, + entry: None, + installed_at: "0000000000000001".to_string(), + nu_version_at_install: Some("0.114.0".to_string()), + activation: None, + registry_url: Some("https://registry.example.com".to_string()), + registry_revision: Some("abc123".to_string()), + index_sha256: None, + signing_key_fingerprint: Some("fingerprint123".to_string()), + git_url: None, + git_rev: None, + cargo_name: None, + cargo_lock_sha256: None, + built_sha256: None, + payload_path: "packages/plugin/nushell/polars/0.114.0-abcd1234".to_string(), + revision_id: None, + payload_sha256: None, + executable_sha256: Some("original_sha".to_string()), + selection_reason: None, + origin: Some("registry:official".to_string()), + module_activation: None, + module_import_mode: None, + locked_dependencies: std::collections::BTreeMap::new(), + }, + ); + lockfile.save(root).unwrap(); + + // Create a version directory with a polars plugin binary + let version_dir = version_manager::version_install_dir(root, "0.114.0"); + std::fs::create_dir_all(&version_dir).unwrap(); + std::fs::write(version_dir.join("nu_plugin_polars"), b"bundled polars").unwrap(); + std::fs::write(version_dir.join("nu_plugin_query"), b"bundled query").unwrap(); + + // Run discovery - should skip polars (registry origin) but add query + discover_bundled_plugins(root, &version_dir, "0.114.0").unwrap(); + + // Verify nushell/polars was NOT overwritten + let lockfile = Lockfile::load(root).unwrap(); + let polars = lockfile + .packages + .get("nushell/polars") + .expect("polars entry must still exist"); + assert_eq!( + polars.origin.as_deref(), + Some("registry:official"), + "registry origin must be preserved" + ); + assert_eq!( + polars.executable_sha256.as_deref(), + Some("original_sha"), + "original SHA must be preserved" + ); + assert_eq!( + polars.artifact_sha256.as_deref(), + Some("registry_sha256_value"), + "registry artifact SHA must be preserved" + ); + assert_eq!( + polars.signing_key_fingerprint.as_deref(), + Some("fingerprint123"), + "signing key fingerprint must be preserved" + ); + + // Verify nushell/query WAS added (no pre-existing entry) + let query = lockfile + .packages + .get("nushell/query") + .expect("query entry must be created"); + assert_eq!(query.origin.as_deref(), Some(BUNDLED_NU_ORIGIN)); + assert_eq!(query.executable_path.as_deref(), Some("nu_plugin_query")); + } + + #[test] + fn discover_bundled_plugins_overwrites_existing_bundled_entry() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + + // Pre-populate a lockfile entry with a bundled origin for nushell/polars + let mut lockfile = Lockfile::load(root).unwrap(); + lockfile.packages.insert( + "nushell/polars".to_string(), + LockfileEntry { + version: "0.113.0".to_string(), + package_type: "plugin".to_string(), + source: "binary".to_string(), + target: None, + artifact_url: None, + artifact_sha256: None, + executable_path: Some("nu_plugin_polars".to_string()), + archive_root: None, + include: None, + entry: None, + installed_at: "0000000000000001".to_string(), + nu_version_at_install: Some("0.113.0".to_string()), + activation: None, + registry_url: None, + registry_revision: None, + index_sha256: None, + signing_key_fingerprint: None, + git_url: None, + git_rev: None, + cargo_name: None, + cargo_lock_sha256: None, + built_sha256: None, + payload_path: "tools/nushell/0.113.0".to_string(), + revision_id: None, + payload_sha256: None, + executable_sha256: Some("old_bundled_sha".to_string()), + selection_reason: None, + origin: Some(BUNDLED_NU_ORIGIN.to_string()), + module_activation: None, + module_import_mode: None, + locked_dependencies: std::collections::BTreeMap::new(), + }, + ); + lockfile.save(root).unwrap(); + + // Create a version directory with updated polars binary + let version_dir = version_manager::version_install_dir(root, "0.114.0"); + std::fs::create_dir_all(&version_dir).unwrap(); + std::fs::write(version_dir.join("nu_plugin_polars"), b"newer polars").unwrap(); + + // Run discovery - should update the existing bundled entry + discover_bundled_plugins(root, &version_dir, "0.114.0").unwrap(); + + // Verify nushell/polars WAS updated (same bundled origin) + let lockfile = Lockfile::load(root).unwrap(); + let polars = lockfile + .packages + .get("nushell/polars") + .expect("polars entry"); + assert_eq!(polars.origin.as_deref(), Some(BUNDLED_NU_ORIGIN)); + assert_eq!(polars.version, "0.114.0"); + let expected_sha = integrity::compute_sha256(b"newer polars"); + assert_eq!( + polars.executable_sha256.as_deref(), + Some(expected_sha.as_str()) + ); } /// Manual smoke: `NUMAN_SMOKE_NU_ARCHIVE=/path/to/nu-*.tar.gz cargo test --lib \ @@ -1348,7 +1866,7 @@ mod tests { archive.display() ); let dir = TempDir::new().unwrap(); - let installed = install_from_archive(&archive, dir.path(), "0.114.1").unwrap(); + let installed = install_from_archive(&archive, dir.path(), "0.114.1", false).unwrap(); assert!(installed.is_file()); assert!(installed.ends_with(nu_binary_name())); // Must be the real shell binary, not a tiny plugin stub. @@ -1369,6 +1887,7 @@ mod tests { force: false, skip_path: true, version: Some("0.113.1".to_string()), + minimal: false, // Install path doesn't enter `register_existing_nu`, but the // initializer needs this field for the struct to compile. caller_consented_destructive: false, @@ -1428,6 +1947,7 @@ mod tests { force: false, skip_path: true, version: Some("0.114.0".to_string()), + minimal: false, caller_consented_destructive: false, is_tty: None, }; @@ -1480,6 +2000,7 @@ mod tests { force: false, skip_path: true, version: Some("0.113.0".to_string()), + minimal: false, caller_consented_destructive: false, is_tty: None, }; @@ -1527,6 +2048,7 @@ mod tests { force: false, skip_path: true, version: Some("0.113.0".to_string()), + minimal: false, caller_consented_destructive: false, is_tty: None, }; @@ -1549,6 +2071,7 @@ mod tests { force: false, skip_path: true, version: Some("0.113.1".to_string()), + minimal: false, caller_consented_destructive: false, is_tty: Some(false), }; @@ -1578,6 +2101,7 @@ mod tests { force: false, skip_path: true, version: None, + minimal: false, caller_consented_destructive: false, is_tty: Some(false), }; @@ -1642,6 +2166,7 @@ mod tests { force: false, skip_path: true, version: None, + minimal: false, caller_consented_destructive: false, is_tty: Some(false), }; diff --git a/src/state/lockfile.rs b/src/state/lockfile.rs index 5ab8b31..fd7b637 100644 --- a/src/state/lockfile.rs +++ b/src/state/lockfile.rs @@ -8,6 +8,10 @@ use crate::core::package::ModuleImportMode; use crate::nupm_compat::schema::NUPM_IMPORT_ORIGIN; use crate::util::atomic::write_json_atomic; +/// Origin marker for plugins bundled with an official Nushell release archive +/// and discovered automatically during `numan setup nu`. +pub const BUNDLED_NU_ORIGIN: &str = "bundled:nu"; + /// Per-Nu-identity activation record stored on a plugin lockfile entry. /// /// A plugin is "currently active" only when this record's hash, version, and diff --git a/src/util/hints.rs b/src/util/hints.rs index 5c6445e..df07975 100644 --- a/src/util/hints.rs +++ b/src/util/hints.rs @@ -205,6 +205,23 @@ pub fn active_plugin_update_list_note(permitted: bool) -> &'static str { } } +/// Hint when a bundled-Nu plugin (origin `bundled:nu`) is targeted by +/// `numan remove`. +/// +/// Bundled plugin lockfile entries share the versioned Nu payload directory +/// (`tools/nushell//`) with the `nu` binary and every other bundled +/// plugin. `remove`'s `remove_dir_all` on that shared payload would destroy +/// the whole managed install, so removal is refused outright and the user is +/// pointed at the managed-Nu removal path. +pub fn bundled_plugin_remove_gated(package_id: &str) -> String { + format!( + "Package '{package_id}' is a bundled Nushell plugin (extracted by `numan setup nu`). \ +Removing it would delete the shared version directory holding the `nu` binary and every other \ +bundled plugin. Remove the whole managed Nu with `numan setup nu remove`, or reinstall with \ +`numan setup nu --minimal` to skip bundled plugins." + ) +} + /// Doctor `fix` field for `activation.plugin_mutation_gated`. /// /// Aligned with [`active_plugin_mutation_gated`], [`active_plugin_update_disabled`], diff --git a/tests/setup_nu_test.rs b/tests/setup_nu_test.rs index e63ca0a..1e781d5 100644 --- a/tests/setup_nu_test.rs +++ b/tests/setup_nu_test.rs @@ -10,6 +10,7 @@ use numan_cli::core::platform::Platform; use numan_cli::nu::bootstrap::{self, install_from_archive, NuSetupOptions}; use numan_cli::nu::paths::{find_nu_executable_with_root, validate_nushell_binary}; use numan_cli::nu::version_manager; +use numan_cli::state::lockfile::{Lockfile, BUNDLED_NU_ORIGIN}; use numan_cli::util::test_paths::PathRestoreGuard; use std::io::Write; use std::path::PathBuf; @@ -36,7 +37,7 @@ fn managed_nu_is_discovered_after_install() { zip.finish().unwrap(); } - install_from_archive(&zip_path, root, "0.0.0-test").unwrap(); + install_from_archive(&zip_path, root, "0.0.0-test", false).unwrap(); // Discovery keys off the active marker; `install_from_archive` alone does // not write it (the setup flow does). Mirror what `numan setup nu` does // so discovery can resolve the freshly installed versioned binary. @@ -73,6 +74,7 @@ fn setup_nu_uses_injected_installer_without_network() { force: false, skip_path: true, version: Some("0.113.1".to_string()), + minimal: false, caller_consented_destructive: false, is_tty: None, }, @@ -110,7 +112,7 @@ fn execute_nu_command_short_circuits_pinned_install_without_network() { std::fs::write(&binary, b"fake nu").unwrap(); execute_nu( - &NuSetupArgs::install(Some(version.to_string()), false, true, true), + &NuSetupArgs::install(Some(version.to_string()), false, true, true, false), root, ) .unwrap(); @@ -314,6 +316,7 @@ fn setup_nu_rejects_use_existing_with_skip_path() { force: false, skip_path: true, yes: true, + minimal: false, remove: false, use_path: false, use_existing: None, @@ -342,7 +345,7 @@ fn setup_nu_rejects_legacy_use_existing_with_skip_path() { let existing = root.join("nu"); std::fs::write(&existing, b"fake nu").unwrap(); - let mut args = NuSetupArgs::install(None, false, true, true); + let mut args = NuSetupArgs::install(None, false, true, true, false); args.use_existing = Some(existing); let err = execute_nu(&args, root).unwrap_err(); @@ -509,3 +512,237 @@ fn register_existing_nu_audit_text_is_stable() { ) ); } + +// --------------------------------------------------------------------------- +// --minimal flag CLI parse test +// --------------------------------------------------------------------------- + +#[test] +fn cli_parse_minimal_flag() { + let args = parse_nu_args(&["--minimal"]); + assert!(args.minimal); + assert!(args.action.is_none()); +} + +#[test] +fn cli_parse_minimal_flag_with_version() { + let args = parse_nu_args(&["--minimal", "0.114.0"]); + assert!(args.minimal); + assert_eq!(args.version.as_deref(), Some("0.114.0")); +} + +// --------------------------------------------------------------------------- +// Bundled plugin extraction and lockfile discovery integration tests +// --------------------------------------------------------------------------- + +#[test] +fn install_from_archive_full_writes_bundled_plugin_lockfile_entries() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let zip_path = root.join("nu-with-plugins.zip"); + + let plugin_polars_content = b"fake polars plugin binary"; + let plugin_formats_content = b"fake formats plugin binary"; + { + let file = std::fs::File::create(&zip_path).unwrap(); + let mut zip = ZipWriter::new(file); + let options = SimpleFileOptions::default(); + let nu_inner = if cfg!(windows) { + "nu-0.114.0-test/nu.exe" + } else { + "nu-0.114.0-test/nu" + }; + zip.start_file(nu_inner, options).unwrap(); + zip.write_all(b"fake nu binary").unwrap(); + zip.start_file("nu-0.114.0-test/nu_plugin_polars", options) + .unwrap(); + zip.write_all(plugin_polars_content).unwrap(); + zip.start_file("nu-0.114.0-test/nu_plugin_formats", options) + .unwrap(); + zip.write_all(plugin_formats_content).unwrap(); + zip.finish().unwrap(); + } + + // Full extraction (minimal=false) + let installed = install_from_archive(&zip_path, root, "0.114.0", false).unwrap(); + assert!(installed.is_file()); + + // Simulate what execute_nu_setup_with_installer does after install + let version_dir = version_manager::version_install_dir(root, "0.114.0"); + assert!(version_dir.join("nu_plugin_polars").exists()); + assert!(version_dir.join("nu_plugin_formats").exists()); + + // Run bundled plugin discovery (public via the setup flow) + // Since discover_bundled_plugins is not directly public, test through the + // full setup flow using the injected installer pattern. + let platform = Platform::detect(); + let _path_guard = PathRestoreGuard::new(); + bootstrap::execute_nu_setup_with_installer( + root, + &platform, + &NuSetupOptions { + yes: true, + force: true, + skip_path: true, + version: Some("0.114.0".to_string()), + minimal: false, + caller_consented_destructive: false, + is_tty: None, + }, + |r, _p| { + // Installer already ran above; just return the existing binary path + Ok(version_manager::version_binary(r, "0.114.0")) + }, + ) + .unwrap(); + + // Verify lockfile entries + let lockfile = Lockfile::load(root).unwrap(); + + let polars = lockfile + .packages + .get("nushell/polars") + .expect("polars lockfile entry must exist"); + assert_eq!(polars.package_type, "plugin"); + assert_eq!(polars.source, "binary"); + assert_eq!(polars.origin.as_deref(), Some(BUNDLED_NU_ORIGIN)); + assert_eq!(polars.executable_path.as_deref(), Some("nu_plugin_polars")); + assert_eq!(polars.payload_path, "tools/nushell/0.114.0"); + assert_eq!(polars.version, "0.114.0"); + assert!(polars.executable_sha256.is_some()); + + let formats = lockfile + .packages + .get("nushell/formats") + .expect("formats lockfile entry must exist"); + assert_eq!(formats.package_type, "plugin"); + assert_eq!(formats.source, "binary"); + assert_eq!(formats.origin.as_deref(), Some(BUNDLED_NU_ORIGIN)); + assert_eq!( + formats.executable_path.as_deref(), + Some("nu_plugin_formats") + ); + assert_eq!(formats.payload_path, "tools/nushell/0.114.0"); +} + +#[test] +fn install_from_archive_minimal_skips_bundled_plugins_in_lockfile() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let zip_path = root.join("nu-with-plugins.zip"); + + { + let file = std::fs::File::create(&zip_path).unwrap(); + let mut zip = ZipWriter::new(file); + let options = SimpleFileOptions::default(); + let nu_inner = if cfg!(windows) { + "nu-0.113.1-test/nu.exe" + } else { + "nu-0.113.1-test/nu" + }; + zip.start_file(nu_inner, options).unwrap(); + zip.write_all(b"fake nu binary").unwrap(); + zip.start_file("nu-0.113.1-test/nu_plugin_polars", options) + .unwrap(); + zip.write_all(b"plugin content").unwrap(); + zip.finish().unwrap(); + } + + // Minimal extraction + let _path_guard = PathRestoreGuard::new(); + let platform = Platform::detect(); + bootstrap::execute_nu_setup_with_installer( + root, + &platform, + &NuSetupOptions { + yes: true, + force: false, + skip_path: true, + version: Some("0.113.1".to_string()), + minimal: true, + caller_consented_destructive: false, + is_tty: None, + }, + |r, _p| { + // Minimal installer: only nu binary + install_from_archive(&zip_path, r, "0.113.1", true) + }, + ) + .unwrap(); + + // Lockfile should NOT have bundled plugin entries + let lockfile = Lockfile::load(root).unwrap(); + assert!( + lockfile.packages.get("nushell/polars").is_none(), + "minimal install must not write bundled plugin lockfile entries" + ); + + // Plugin binary should NOT exist on disk + let version_dir = version_manager::version_install_dir(root, "0.113.1"); + assert!( + !version_dir.join("nu_plugin_polars").exists(), + "minimal install must not place plugin binary on disk" + ); +} + +#[test] +fn list_shows_bundled_with_nu_tag() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + + // Create a lockfile with a bundled entry + let mut lockfile = Lockfile::load(root).unwrap(); + lockfile.packages.insert( + "nushell/polars".to_string(), + numan_cli::state::lockfile::LockfileEntry { + version: "0.114.0".to_string(), + package_type: "plugin".to_string(), + source: "binary".to_string(), + target: None, + artifact_url: None, + artifact_sha256: None, + executable_path: Some("nu_plugin_polars".to_string()), + archive_root: None, + include: None, + entry: None, + installed_at: "0".to_string(), + nu_version_at_install: Some("0.114.0".to_string()), + activation: None, + registry_url: None, + registry_revision: None, + index_sha256: None, + signing_key_fingerprint: None, + git_url: None, + git_rev: None, + cargo_name: None, + cargo_lock_sha256: None, + built_sha256: None, + payload_path: "tools/nushell/0.114.0".to_string(), + revision_id: None, + payload_sha256: None, + executable_sha256: Some("abcdef".to_string()), + selection_reason: None, + origin: Some(BUNDLED_NU_ORIGIN.to_string()), + module_activation: None, + module_import_mode: None, + locked_dependencies: Default::default(), + }, + ); + lockfile.save(root).unwrap(); + + // Run `numan list` and capture output + let output = std::process::Command::new(env!("CARGO_BIN_EXE_numan")) + .args(["list", "--root", root.to_str().unwrap()]) + .output() + .expect("failed to run numan list"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("(bundled with Nu)"), + "numan list must show (bundled with Nu) tag; got: {stdout}" + ); + assert!( + stdout.contains("nushell/polars"), + "numan list must show the bundled plugin; got: {stdout}" + ); +}