Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/cmd/list.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 {
""
};
Expand Down
2 changes: 1 addition & 1 deletion src/cmd/nu_pin_offer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"))?;
Expand Down
84 changes: 83 additions & 1 deletion src/cmd/remove.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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. \
Expand Down Expand Up @@ -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. \
Expand Down Expand Up @@ -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/<version>/` 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::*;
Expand Down Expand Up @@ -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 {
Expand Down
21 changes: 20 additions & 1 deletion src/cmd/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -117,13 +121,20 @@ pub enum NuAction {

impl NuSetupArgs {
/// Construct args for installing a managed Nu (latest or pinned).
pub fn install(version: Option<String>, force: bool, skip_path: bool, yes: bool) -> Self {
pub fn install(
version: Option<String>,
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,
Expand All @@ -144,6 +155,7 @@ impl NuSetupArgs {
force: false,
skip_path: false,
yes,
minimal: false,
remove: false,
use_path: false,
use_existing: None,
Expand All @@ -158,6 +170,7 @@ impl NuSetupArgs {
force: false,
skip_path: false,
yes,
minimal: false,
remove: false,
use_path: false,
use_existing: None,
Expand All @@ -172,6 +185,7 @@ impl NuSetupArgs {
force: false,
skip_path,
yes,
minimal: false,
remove: false,
use_path: false,
use_existing: None,
Expand All @@ -186,6 +200,7 @@ impl NuSetupArgs {
force: false,
skip_path: false,
yes,
minimal: false,
remove: false,
use_path: false,
use_existing: None,
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1255,6 +1273,7 @@ mod tests {
force: false,
skip_path: false,
yes: true,
minimal: false,
remove: true,
use_path: false,
use_existing: None,
Expand Down
Loading