Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
8 changes: 8 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ pub enum Commands {
/// Shell to use (defaults to $SHELL or bash)
#[arg(short, long)]
shell: Option<String>,

/// 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
Expand Down
41 changes: 41 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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);
Expand Down
31 changes: 28 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand Down Expand Up @@ -187,15 +187,40 @@ fn cmd_shell(
packages: &[String],
shell: Option<String>,
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(())
Expand Down
216 changes: 215 additions & 1 deletion src/shell.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -119,3 +125,211 @@ pub fn generate_env_script(shell: &str, env: &HashMap<String, String>) -> 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<String, String>) -> Result<PathBuf> {
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<String, String>, 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");
}
}
13 changes: 13 additions & 0 deletions tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
Loading