diff --git a/Cargo.toml b/Cargo.toml index f7e4f90..38745f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,9 +44,9 @@ regex = "1.10" # Cross-platform cfg-if = "1.0" +tempfile = "3.10" [dev-dependencies] -tempfile = "3.10" assert_cmd = "2.0" predicates = "3.1" diff --git a/src/cli.rs b/src/cli.rs index 1628a92..9062521 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -57,6 +57,14 @@ pub enum Commands { /// Shell to use (defaults to $SHELL or bash) #[arg(short, long)] shell: Option, + + /// Don't materialise `commands:` as PATH shims — compose the env only. + #[arg(long)] + env_only: bool, + + /// Skip the orphan-shim sweep on entry (debugging aid). + #[arg(long)] + no_sweep: bool, }, /// List available packages diff --git a/src/config.rs b/src/config.rs index 5060eb9..50d2a5c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -31,6 +31,41 @@ pub struct Config { /// Package include/exclude filters #[serde(default)] pub filters: FiltersConfig, + + /// `anvil shell` behaviour + #[serde(default)] + pub shell: ShellConfig, +} + +/// Controls how `anvil shell` composes the interactive subshell. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ShellConfig { + /// Materialize `commands:` declarations as PATH shims inside the subshell. + #[serde(default = "default_true")] + pub inject_commands: bool, + + /// Orphaned shim tempdirs older than this (seconds) are swept on + /// `anvil shell` entry. Orphans happen when a shell is SIGKILL'd before + /// its tempdir handle is cleaned up. + #[serde(default = "default_orphan_ttl")] + pub orphan_ttl: u64, +} + +fn default_true() -> bool { + true +} + +fn default_orphan_ttl() -> u64 { + 3600 +} + +impl Default for ShellConfig { + fn default() -> Self { + ShellConfig { + inject_commands: true, + orphan_ttl: 3600, + } + } } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -323,6 +358,12 @@ impl Config { self.filters = project.filters; } + // Shell: project shell config replaces global only if it differs from + // the default (serde fills in the default when the project omits `shell:`). + if project.shell != ShellConfig::default() { + self.shell = project.shell; + } + // Merge per-platform paths (project first) Self::merge_platform(&mut self.platform.linux, project.platform.linux); Self::merge_platform(&mut self.platform.macos, project.platform.macos); diff --git a/src/main.rs b/src/main.rs index 75277f8..07a74b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,8 +42,8 @@ fn main() -> Result<()> { Commands::Run { packages, env_vars, command } => { cmd_run(&config, &packages, &env_vars, &command, refresh)?; } - Commands::Shell { packages, shell } => { - cmd_shell(&config, &packages, shell, refresh)?; + Commands::Shell { packages, shell, env_only, no_sweep } => { + cmd_shell(&config, &packages, shell, refresh, env_only, no_sweep)?; } Commands::List { package } => { cmd_list(&config, package, refresh)?; @@ -187,15 +187,40 @@ fn cmd_shell( packages: &[String], shell: Option, refresh: bool, + env_only: bool, + no_sweep: bool, ) -> Result<()> { let resolver = Resolver::new(config, refresh)?; let resolved = resolver.resolve(packages)?; - let env = resolved.environment(); + let mut env = resolved.environment(); let shell_path = shell .or_else(|| config.default_shell.clone()) .unwrap_or_else(|| shell::detect_shell()); + // Opt-outs, in priority order: + // 1. --env-only flag + // 2. ANVIL_DISABLE_COMMAND_SHIMS env var (useful in CI) + // 3. `shell.inject_commands: false` in config + let disabled_by_env = std::env::var_os("ANVIL_DISABLE_COMMAND_SHIMS").is_some(); + let inject = !env_only && !disabled_by_env && config.shell.inject_commands; + + if inject { + if !no_sweep { + shell::sweep_stale_shims(std::time::Duration::from_secs(config.shell.orphan_ttl)); + } + + let commands = resolved.commands(); + if !commands.is_empty() { + let shim_dir = shell::materialize_commands(&commands)?; + shell::prepend_path(&mut env, &shim_dir); + env.insert( + "ANVIL_COMMAND_DIR".to_string(), + shim_dir.to_string_lossy().into_owned(), + ); + } + } + shell::spawn_shell(&shell_path, &env)?; Ok(()) diff --git a/src/shell.rs b/src/shell.rs index 5743639..54c4eb9 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -1,9 +1,15 @@ //! Shell spawning and detection use std::collections::HashMap; +use std::path::{Path, PathBuf}; use std::process::Command; +use std::time::SystemTime; -use anyhow::Result; +use anyhow::{Context, Result}; + +/// Prefix used for every anvil-managed shim tempdir so the sweeper can find +/// orphans reliably. +pub const SHIM_DIR_PREFIX: &str = "anvil-shell-"; /// Detect the user's preferred shell pub fn detect_shell() -> String { @@ -119,3 +125,211 @@ pub fn generate_env_script(shell: &str, env: &HashMap) -> String } } } + +/// Write a PATH shim for each `(alias, command)` pair into a fresh tempdir +/// and return the path. The tempdir is *leaked* on purpose — its lifetime +/// is the interactive subshell the caller is about to spawn, and the sweeper +/// reclaims it on the next `anvil shell` invocation. +/// +/// On POSIX the shim is a `chmod 755` shebang script; on Windows it's a +/// `.cmd` wrapper resolvable through PATHEXT by cmd.exe, PowerShell, and +/// pwsh alike. +pub fn materialize_commands(commands: &HashMap) -> Result { + let dir = tempfile::Builder::new() + .prefix(SHIM_DIR_PREFIX) + .tempdir() + .context("Failed to create shim tempdir")? + // Disown so it survives the `exec` into the user's shell. The + // sweeper below reclaims it on the next `anvil shell` invocation. + .keep(); + + for (alias, target) in commands { + write_shim(&dir, alias, target) + .with_context(|| format!("Failed to write shim for {:?}", alias))?; + } + + Ok(dir) +} + +#[cfg(unix)] +fn write_shim(dir: &Path, alias: &str, target: &str) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + // `target` is already the expanded command string — e.g. a bare path or + // `"/Applications/... Painter" --flag`. `exec "$@"` with the command + // embedded raw preserves any baked-in arguments and lets the user append + // their own. + let script = format!("#!/usr/bin/env bash\nexec {} \"$@\"\n", target); + let path = dir.join(alias); + std::fs::write(&path, script)?; + let mut perms = std::fs::metadata(&path)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&path, perms)?; + Ok(()) +} + +#[cfg(windows)] +fn write_shim(dir: &Path, alias: &str, target: &str) -> Result<()> { + // `.cmd` is resolvable through PATHEXT by cmd.exe, PowerShell, and pwsh. + // `%*` forwards every argument the user typed. + let script = format!("@echo off\r\n{} %*\r\n", target); + let path = dir.join(format!("{}.cmd", alias)); + std::fs::write(&path, script)?; + Ok(()) +} + +/// Delete `anvil-shell-*` tempdirs inside `root` whose mtime is older than +/// `ttl`. Runs before materialising the current session's shims so a +/// SIGKILL'd shell never leaks more than one orphan. +/// +/// Failures are best-effort: a file we can't stat or unlink is skipped +/// rather than aborting the shell entry. +pub fn sweep_stale_shims_in(root: &Path, ttl: std::time::Duration) { + let Ok(entries) = std::fs::read_dir(root) else { + return; + }; + let now = SystemTime::now(); + + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name_str) = name.to_str() else { continue }; + if !name_str.starts_with(SHIM_DIR_PREFIX) { + continue; + } + + let Ok(meta) = entry.metadata() else { continue }; + if !meta.is_dir() { + continue; + } + let Ok(mtime) = meta.modified() else { continue }; + let Ok(age) = now.duration_since(mtime) else { continue }; + if age < ttl { + continue; + } + + let _ = std::fs::remove_dir_all(entry.path()); + } +} + +/// Default sweep: walks the system temp dir. +pub fn sweep_stale_shims(ttl: std::time::Duration) { + sweep_stale_shims_in(&std::env::temp_dir(), ttl); +} + +/// Prepend `shim_dir` to the `PATH` entry in `env`, creating the entry if +/// absent. Separator is platform-native (`:` on Unix, `;` on Windows). +pub fn prepend_path(env: &mut HashMap, shim_dir: &Path) { + let sep = if cfg!(windows) { ';' } else { ':' }; + let shim_str = shim_dir.to_string_lossy().into_owned(); + let new_path = match env.get("PATH") { + Some(existing) if !existing.is_empty() => format!("{}{}{}", shim_str, sep, existing), + _ => shim_str, + }; + env.insert("PATH".to_string(), new_path); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn materialize_writes_shims() { + let mut cmds = HashMap::new(); + cmds.insert("hello".to_string(), "/bin/echo hi".to_string()); + let dir = materialize_commands(&cmds).unwrap(); + + #[cfg(unix)] + let shim = dir.join("hello"); + #[cfg(windows)] + let shim = dir.join("hello.cmd"); + + assert!(shim.exists(), "shim file should exist at {:?}", shim); + let content = std::fs::read_to_string(&shim).unwrap(); + assert!(content.contains("/bin/echo hi"), "content was: {}", content); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&shim).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o755); + } + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn shim_roundtrip_executes_on_unix() { + // This test exercises the actual shim as an executable; Windows + // .cmd invocation is harder to drive from Rust tests, so restrict + // to Unix where `/bin/echo` is reliably present. + #[cfg(unix)] + { + let mut cmds = HashMap::new(); + cmds.insert("anvil_test_echo".to_string(), "/bin/echo ok".to_string()); + let dir = materialize_commands(&cmds).unwrap(); + let out = std::process::Command::new(dir.join("anvil_test_echo")) + .arg("extra") + .output() + .expect("shim should be executable"); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.trim() == "ok extra", "got: {:?}", stdout); + std::fs::remove_dir_all(&dir).ok(); + } + } + + #[test] + fn sweep_removes_stale_dirs() { + // Isolate to a private root so a parallel test doesn't lose its dir. + let root = tempfile::tempdir().unwrap(); + let stale = tempfile::Builder::new() + .prefix(SHIM_DIR_PREFIX) + .tempdir_in(root.path()) + .unwrap() + .keep(); + assert!(stale.exists()); + sweep_stale_shims_in(root.path(), Duration::from_secs(0)); + assert!(!stale.exists(), "sweep should have deleted {:?}", stale); + } + + #[test] + fn sweep_keeps_fresh_dirs() { + let root = tempfile::tempdir().unwrap(); + let fresh = tempfile::Builder::new() + .prefix(SHIM_DIR_PREFIX) + .tempdir_in(root.path()) + .unwrap() + .keep(); + sweep_stale_shims_in(root.path(), Duration::from_secs(3600)); + assert!(fresh.exists()); + } + + #[test] + fn sweep_ignores_non_anvil_dirs() { + let root = tempfile::tempdir().unwrap(); + let other = tempfile::Builder::new() + .prefix("unrelated-") + .tempdir_in(root.path()) + .unwrap() + .keep(); + sweep_stale_shims_in(root.path(), Duration::from_secs(0)); + assert!(other.exists(), "sweep should ignore non-anvil dirs"); + } + + #[test] + fn prepend_path_inserts_separator() { + let mut env = HashMap::new(); + env.insert("PATH".to_string(), "/usr/bin".to_string()); + prepend_path(&mut env, Path::new("/tmp/shim")); + let sep = if cfg!(windows) { ';' } else { ':' }; + assert_eq!(env["PATH"], format!("/tmp/shim{}/usr/bin", sep)); + } + + #[test] + fn prepend_path_handles_missing_path() { + let mut env = HashMap::new(); + prepend_path(&mut env, Path::new("/tmp/shim")); + assert_eq!(env["PATH"], "/tmp/shim"); + } +} diff --git a/tests/cli.rs b/tests/cli.rs index 9ee853e..92f5f76 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -878,3 +878,16 @@ fn publish_refuses_overwrite() { .assert() .failure(); } + +// ---- anvil shell flags ---- + +#[test] +fn shell_help_exposes_shim_flags() { + let (_dir, cfg) = setup_env(); + anvil(&cfg) + .args(["shell", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--env-only")) + .stdout(predicate::str::contains("--no-sweep")); +}