diff --git a/.gitignore b/.gitignore index 68a2473..fbdecca 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ __pycache__ settings.local.json .claude/worktrees/ CLAUDE.local.md +.sandcage.local.yml diff --git a/.sandcage.yml b/.sandcage.yml index 65a29b6..9a62796 100644 --- a/.sandcage.yml +++ b/.sandcage.yml @@ -10,8 +10,6 @@ packages: toolchains: rust: stable -mounts: -- ~/.ssh:/home/agent/.ssh:ro # mounts: # - ~/.ssh:/home/agent/.ssh:ro diff --git a/crates/sandcage/src/config.rs b/crates/sandcage/src/config.rs index fb94136..6468ecf 100644 --- a/crates/sandcage/src/config.rs +++ b/crates/sandcage/src/config.rs @@ -225,13 +225,13 @@ pub fn load_trusted_projects(global_config_path: &Path) -> Vec { /// 1. Compiled defaults (all `None`) /// 2. Global config from `global_config_path` (TOML, e.g. `~/.sandcage/config.toml`) /// 3. Project config from `project_config_path` (YAML, e.g. `.sandcage.yml`) +/// 4. Local project config from `local_config_path` (YAML, e.g. `.sandcage.local.yml`) /// -/// Later layers win: project values override global values, which override defaults. -/// -/// Pass `None` for either path to skip that layer (e.g. when the file doesn't exist). +/// Later layers win. Pass `None` for any path to skip that layer. pub fn resolve_config( global_config_path: Option<&Path>, project_config_path: Option<&Path>, + local_config_path: Option<&Path>, ) -> Result { // Start with compiled defaults — all fields None. let mut figment = Figment::from(Serialized::defaults(SandcageConfig::default())); @@ -249,6 +249,13 @@ pub fn resolve_config( figment = figment.merge(Serialized::defaults(project_cfg)); } + if let Some(local_path) = local_config_path + && local_path.exists() + { + let local_cfg = load(local_path)?; + figment = figment.merge(Serialized::defaults(local_cfg)); + } + figment .extract() .map_err(|e| ConfigError::MergeFailed(Box::new(e))) @@ -442,7 +449,7 @@ justfile: ./project.justfile #[test] fn resolve_defaults_only_all_none() { - let cfg = resolve_config(None, None).expect("resolve with no files"); + let cfg = resolve_config(None, None, None).expect("resolve with no files"); assert!(cfg.env.is_none()); assert!(cfg.packages.is_none()); assert!(cfg.toolchains.is_none()); @@ -462,7 +469,7 @@ EDITOR = "vim" let mut global_tmp = NamedTempFile::new().expect("create global tmpfile"); write!(global_tmp, "{toml_content}").expect("write global tmpfile"); - let cfg = resolve_config(Some(global_tmp.path()), None).expect("resolve global only"); + let cfg = resolve_config(Some(global_tmp.path()), None, None).expect("resolve global only"); assert_eq!(cfg.shell.as_deref(), Some("zsh")); let env = cfg.env.expect("env should be Some from global"); assert_eq!(env.get("EDITOR").map(String::as_str), Some("vim")); @@ -475,7 +482,7 @@ EDITOR = "vim" let mut project_tmp = NamedTempFile::new().expect("create project tmpfile"); write!(project_tmp, "{yaml_content}").expect("write project tmpfile"); - let cfg = resolve_config(None, Some(project_tmp.path())).expect("resolve project only"); + let cfg = resolve_config(None, Some(project_tmp.path()), None).expect("resolve project only"); assert_eq!(cfg.shell.as_deref(), Some("bash")); let pkgs = cfg.packages.expect("packages should be Some"); assert_eq!(pkgs, vec!["ripgrep"]); @@ -494,7 +501,7 @@ EDITOR = "vim" let mut project_tmp = NamedTempFile::new().expect("create project tmpfile"); write!(project_tmp, "{project_yaml}").expect("write project tmpfile"); - let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path())) + let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path()), None) .expect("resolve both"); assert_eq!(cfg.shell.as_deref(), Some("bash"), "project shell should override global"); } @@ -512,7 +519,7 @@ EDITOR = "vim" let mut project_tmp = NamedTempFile::new().expect("create project tmpfile"); write!(project_tmp, "{project_yaml}").expect("write project tmpfile"); - let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path())) + let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path()), None) .expect("resolve partial overlap"); assert_eq!(cfg.shell.as_deref(), Some("zsh"), "shell should come from global"); let env = cfg.env.expect("env should be Some from global"); @@ -527,6 +534,7 @@ EDITOR = "vim" let cfg = resolve_config( Some(Path::new("/nonexistent/config.toml")), Some(Path::new("/nonexistent/.sandcage.yml")), + None, ) .expect("nonexistent files should not error"); assert!(cfg.shell.is_none()); @@ -567,7 +575,7 @@ agent_args: let mut project_tmp = NamedTempFile::new().expect("create project tmpfile"); write!(project_tmp, "{project_yaml}").expect("write project tmpfile"); - let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path())) + let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path()), None) .expect("resolve"); let agent_args = cfg.agent_args.expect("agent_args should be Some"); let claude_args = agent_args.get("claude").expect("claude args"); @@ -631,7 +639,7 @@ ssh_keys: write!(global_tmp, "{global_toml}").expect("write global tmpfile"); let mut project_tmp = NamedTempFile::new().expect("create project tmpfile"); write!(project_tmp, "{project_yaml}").expect("write project tmpfile"); - let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path())).expect("resolve"); + let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path()), None).expect("resolve"); assert_eq!(cfg.ssh_mode.as_deref(), Some("volume")); } } diff --git a/crates/sandcage/src/docker.rs b/crates/sandcage/src/docker.rs index 6c88a7d..4ab80a5 100644 --- a/crates/sandcage/src/docker.rs +++ b/crates/sandcage/src/docker.rs @@ -492,7 +492,7 @@ pub fn build_images(force: bool) -> Result<()> { let project_config = project_dir.join(".sandcage.yml"); // Global overrides are always trusted. - let global_cfg = crate::config::resolve_config(Some(&global_config), None) + let global_cfg = crate::config::resolve_config(Some(&global_config), None, None) .unwrap_or_default(); let mut overrides = global_cfg.dockerfiles.unwrap_or_default(); diff --git a/crates/sandcage/src/init.rs b/crates/sandcage/src/init.rs index 9bf38ca..0e815a5 100644 --- a/crates/sandcage/src/init.rs +++ b/crates/sandcage/src/init.rs @@ -72,6 +72,25 @@ pub fn preseed_with_home(home: &Path) -> Result<()> { Ok(()) } +fn ensure_gitignore_entry(project_dir: &Path, entry: &str) -> Result<()> { + let gitignore_path = project_dir.join(".gitignore"); + let content = std::fs::read_to_string(&gitignore_path).unwrap_or_default(); + if content.lines().any(|line| line.trim() == entry) { + return Ok(()); + } + let separator = if content.is_empty() || content.ends_with('\n') { "" } else { "\n" }; + let addition = format!("{separator}{entry}\n"); + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&gitignore_path) + .and_then(|mut f| { + use std::io::Write; + f.write_all(addition.as_bytes()) + }) + .map_err(|e| InitError::WriteFileFailed(gitignore_path, e)) +} + fn create_dir_all(path: &Path) -> Result<()> { std::fs::create_dir_all(path) .map_err(|e| InitError::CreateDirFailed(path.to_path_buf(), e)) @@ -179,6 +198,8 @@ pub fn scaffold_project(path: &Path) -> Result<()> { std::fs::write(&config_path, &yaml) .map_err(|e| InitError::WriteFileFailed(config_path.clone(), e))?; + ensure_gitignore_entry(path, ".sandcage.local.yml")?; + println!( "sandcage: initialised {} (detected ecosystem: {})", config_path.display(), diff --git a/crates/sandcage/src/main.rs b/crates/sandcage/src/main.rs index de66fc7..b548490 100644 --- a/crates/sandcage/src/main.rs +++ b/crates/sandcage/src/main.rs @@ -122,9 +122,11 @@ fn run_agent(service: &str, path: Option, shell_override: bool, agent_a let home = dirs::home_dir().ok_or(init::InitError::NoHomeDir)?; let global_config = home.join(".sandcage").join("config.toml"); let project_config = workspace.join(".sandcage.yml"); + let local_config = workspace.join(".sandcage.local.yml"); let cfg = config::resolve_config( Some(&global_config), Some(&project_config), + Some(&local_config), )?; eprintln!("sandcage: service \u{2192} {service}"); diff --git a/crates/sandcage/src/setup.rs b/crates/sandcage/src/setup.rs index 3e73078..ad0fa07 100644 --- a/crates/sandcage/src/setup.rs +++ b/crates/sandcage/src/setup.rs @@ -581,16 +581,12 @@ pub fn run_ssh_setup(global: bool, yes: bool, refresh: bool, bind: bool) -> Resu } // Determine config target - let (config_path, is_global) = if global { - (home.join(".sandcage").join("config.toml"), true) + let (config_path, config_label, is_global) = if global { + (home.join(".sandcage").join("config.toml"), "~/.sandcage/config.toml", true) } else { let cwd = std::env::current_dir() .map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?; - let project_config = cwd.join(".sandcage.yml"); - if !project_config.exists() { - return Err(SetupError::NoProjectConfig); - } - (project_config, false) + (cwd.join(".sandcage.local.yml"), ".sandcage.local.yml", false) }; // Write config @@ -608,11 +604,6 @@ pub fn run_ssh_setup(global: bool, yes: bool, refresh: bool, bind: bool) -> Resu eprintln!("sandcage: populating SSH volume..."); populate_ssh_volume(&selected_entries, &ssh_blocks, include_known_hosts)?; - let config_label = if is_global { - "~/.sandcage/config.toml" - } else { - ".sandcage.yml" - }; eprintln!("sandcage: SSH keys copied to volume 'sandcage-ssh'"); eprintln!("sandcage: configuration saved to {config_label}"); @@ -627,7 +618,7 @@ fn run_ssh_refresh(global: bool, ssh_dir: &Path) -> Result<()> { } else { let cwd = std::env::current_dir() .map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?; - cwd.join(".sandcage.yml") + cwd.join(".sandcage.local.yml") }; if !config_path.exists() {