🥇 export from upstream (b86fc6f)
This commit is contained in:
@@ -31,3 +31,4 @@ __pycache__
|
|||||||
settings.local.json
|
settings.local.json
|
||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
CLAUDE.local.md
|
CLAUDE.local.md
|
||||||
|
.sandcage.local.yml
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ packages:
|
|||||||
|
|
||||||
toolchains:
|
toolchains:
|
||||||
rust: stable
|
rust: stable
|
||||||
mounts:
|
|
||||||
- ~/.ssh:/home/agent/.ssh:ro
|
|
||||||
|
|
||||||
# mounts:
|
# mounts:
|
||||||
# - ~/.ssh:/home/agent/.ssh:ro
|
# - ~/.ssh:/home/agent/.ssh:ro
|
||||||
|
|||||||
@@ -225,13 +225,13 @@ pub fn load_trusted_projects(global_config_path: &Path) -> Vec<PathBuf> {
|
|||||||
/// 1. Compiled defaults (all `None`)
|
/// 1. Compiled defaults (all `None`)
|
||||||
/// 2. Global config from `global_config_path` (TOML, e.g. `~/.sandcage/config.toml`)
|
/// 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`)
|
/// 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.
|
/// Later layers win. Pass `None` for any path to skip that layer.
|
||||||
///
|
|
||||||
/// Pass `None` for either path to skip that layer (e.g. when the file doesn't exist).
|
|
||||||
pub fn resolve_config(
|
pub fn resolve_config(
|
||||||
global_config_path: Option<&Path>,
|
global_config_path: Option<&Path>,
|
||||||
project_config_path: Option<&Path>,
|
project_config_path: Option<&Path>,
|
||||||
|
local_config_path: Option<&Path>,
|
||||||
) -> Result<SandcageConfig> {
|
) -> Result<SandcageConfig> {
|
||||||
// Start with compiled defaults — all fields None.
|
// Start with compiled defaults — all fields None.
|
||||||
let mut figment = Figment::from(Serialized::defaults(SandcageConfig::default()));
|
let mut figment = Figment::from(Serialized::defaults(SandcageConfig::default()));
|
||||||
@@ -249,6 +249,13 @@ pub fn resolve_config(
|
|||||||
figment = figment.merge(Serialized::defaults(project_cfg));
|
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
|
figment
|
||||||
.extract()
|
.extract()
|
||||||
.map_err(|e| ConfigError::MergeFailed(Box::new(e)))
|
.map_err(|e| ConfigError::MergeFailed(Box::new(e)))
|
||||||
@@ -442,7 +449,7 @@ justfile: ./project.justfile
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_defaults_only_all_none() {
|
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.env.is_none());
|
||||||
assert!(cfg.packages.is_none());
|
assert!(cfg.packages.is_none());
|
||||||
assert!(cfg.toolchains.is_none());
|
assert!(cfg.toolchains.is_none());
|
||||||
@@ -462,7 +469,7 @@ EDITOR = "vim"
|
|||||||
let mut global_tmp = NamedTempFile::new().expect("create global tmpfile");
|
let mut global_tmp = NamedTempFile::new().expect("create global tmpfile");
|
||||||
write!(global_tmp, "{toml_content}").expect("write 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"));
|
assert_eq!(cfg.shell.as_deref(), Some("zsh"));
|
||||||
let env = cfg.env.expect("env should be Some from global");
|
let env = cfg.env.expect("env should be Some from global");
|
||||||
assert_eq!(env.get("EDITOR").map(String::as_str), Some("vim"));
|
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");
|
let mut project_tmp = NamedTempFile::new().expect("create project tmpfile");
|
||||||
write!(project_tmp, "{yaml_content}").expect("write 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"));
|
assert_eq!(cfg.shell.as_deref(), Some("bash"));
|
||||||
let pkgs = cfg.packages.expect("packages should be Some");
|
let pkgs = cfg.packages.expect("packages should be Some");
|
||||||
assert_eq!(pkgs, vec!["ripgrep"]);
|
assert_eq!(pkgs, vec!["ripgrep"]);
|
||||||
@@ -494,7 +501,7 @@ EDITOR = "vim"
|
|||||||
let mut project_tmp = NamedTempFile::new().expect("create project tmpfile");
|
let mut project_tmp = NamedTempFile::new().expect("create project tmpfile");
|
||||||
write!(project_tmp, "{project_yaml}").expect("write 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");
|
.expect("resolve both");
|
||||||
assert_eq!(cfg.shell.as_deref(), Some("bash"), "project shell should override global");
|
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");
|
let mut project_tmp = NamedTempFile::new().expect("create project tmpfile");
|
||||||
write!(project_tmp, "{project_yaml}").expect("write 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");
|
.expect("resolve partial overlap");
|
||||||
assert_eq!(cfg.shell.as_deref(), Some("zsh"), "shell should come from global");
|
assert_eq!(cfg.shell.as_deref(), Some("zsh"), "shell should come from global");
|
||||||
let env = cfg.env.expect("env should be Some from global");
|
let env = cfg.env.expect("env should be Some from global");
|
||||||
@@ -527,6 +534,7 @@ EDITOR = "vim"
|
|||||||
let cfg = resolve_config(
|
let cfg = resolve_config(
|
||||||
Some(Path::new("/nonexistent/config.toml")),
|
Some(Path::new("/nonexistent/config.toml")),
|
||||||
Some(Path::new("/nonexistent/.sandcage.yml")),
|
Some(Path::new("/nonexistent/.sandcage.yml")),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("nonexistent files should not error");
|
.expect("nonexistent files should not error");
|
||||||
assert!(cfg.shell.is_none());
|
assert!(cfg.shell.is_none());
|
||||||
@@ -567,7 +575,7 @@ agent_args:
|
|||||||
let mut project_tmp = NamedTempFile::new().expect("create project tmpfile");
|
let mut project_tmp = NamedTempFile::new().expect("create project tmpfile");
|
||||||
write!(project_tmp, "{project_yaml}").expect("write 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");
|
.expect("resolve");
|
||||||
let agent_args = cfg.agent_args.expect("agent_args should be Some");
|
let agent_args = cfg.agent_args.expect("agent_args should be Some");
|
||||||
let claude_args = agent_args.get("claude").expect("claude args");
|
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");
|
write!(global_tmp, "{global_toml}").expect("write global tmpfile");
|
||||||
let mut project_tmp = NamedTempFile::new().expect("create project tmpfile");
|
let mut project_tmp = NamedTempFile::new().expect("create project tmpfile");
|
||||||
write!(project_tmp, "{project_yaml}").expect("write 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"));
|
assert_eq!(cfg.ssh_mode.as_deref(), Some("volume"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -492,7 +492,7 @@ pub fn build_images(force: bool) -> Result<()> {
|
|||||||
let project_config = project_dir.join(".sandcage.yml");
|
let project_config = project_dir.join(".sandcage.yml");
|
||||||
|
|
||||||
// Global overrides are always trusted.
|
// 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();
|
.unwrap_or_default();
|
||||||
let mut overrides = global_cfg.dockerfiles.unwrap_or_default();
|
let mut overrides = global_cfg.dockerfiles.unwrap_or_default();
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,25 @@ pub fn preseed_with_home(home: &Path) -> Result<()> {
|
|||||||
Ok(())
|
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<()> {
|
fn create_dir_all(path: &Path) -> Result<()> {
|
||||||
std::fs::create_dir_all(path)
|
std::fs::create_dir_all(path)
|
||||||
.map_err(|e| InitError::CreateDirFailed(path.to_path_buf(), e))
|
.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)
|
std::fs::write(&config_path, &yaml)
|
||||||
.map_err(|e| InitError::WriteFileFailed(config_path.clone(), e))?;
|
.map_err(|e| InitError::WriteFileFailed(config_path.clone(), e))?;
|
||||||
|
|
||||||
|
ensure_gitignore_entry(path, ".sandcage.local.yml")?;
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"sandcage: initialised {} (detected ecosystem: {})",
|
"sandcage: initialised {} (detected ecosystem: {})",
|
||||||
config_path.display(),
|
config_path.display(),
|
||||||
|
|||||||
@@ -122,9 +122,11 @@ fn run_agent(service: &str, path: Option<PathBuf>, shell_override: bool, agent_a
|
|||||||
let home = dirs::home_dir().ok_or(init::InitError::NoHomeDir)?;
|
let home = dirs::home_dir().ok_or(init::InitError::NoHomeDir)?;
|
||||||
let global_config = home.join(".sandcage").join("config.toml");
|
let global_config = home.join(".sandcage").join("config.toml");
|
||||||
let project_config = workspace.join(".sandcage.yml");
|
let project_config = workspace.join(".sandcage.yml");
|
||||||
|
let local_config = workspace.join(".sandcage.local.yml");
|
||||||
let cfg = config::resolve_config(
|
let cfg = config::resolve_config(
|
||||||
Some(&global_config),
|
Some(&global_config),
|
||||||
Some(&project_config),
|
Some(&project_config),
|
||||||
|
Some(&local_config),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
eprintln!("sandcage: service \u{2192} {service}");
|
eprintln!("sandcage: service \u{2192} {service}");
|
||||||
|
|||||||
@@ -581,16 +581,12 @@ pub fn run_ssh_setup(global: bool, yes: bool, refresh: bool, bind: bool) -> Resu
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Determine config target
|
// Determine config target
|
||||||
let (config_path, is_global) = if global {
|
let (config_path, config_label, is_global) = if global {
|
||||||
(home.join(".sandcage").join("config.toml"), true)
|
(home.join(".sandcage").join("config.toml"), "~/.sandcage/config.toml", true)
|
||||||
} else {
|
} else {
|
||||||
let cwd = std::env::current_dir()
|
let cwd = std::env::current_dir()
|
||||||
.map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?;
|
.map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?;
|
||||||
let project_config = cwd.join(".sandcage.yml");
|
(cwd.join(".sandcage.local.yml"), ".sandcage.local.yml", false)
|
||||||
if !project_config.exists() {
|
|
||||||
return Err(SetupError::NoProjectConfig);
|
|
||||||
}
|
|
||||||
(project_config, false)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Write config
|
// 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...");
|
eprintln!("sandcage: populating SSH volume...");
|
||||||
populate_ssh_volume(&selected_entries, &ssh_blocks, include_known_hosts)?;
|
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: SSH keys copied to volume 'sandcage-ssh'");
|
||||||
eprintln!("sandcage: configuration saved to {config_label}");
|
eprintln!("sandcage: configuration saved to {config_label}");
|
||||||
|
|
||||||
@@ -627,7 +618,7 @@ fn run_ssh_refresh(global: bool, ssh_dir: &Path) -> Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
let cwd = std::env::current_dir()
|
let cwd = std::env::current_dir()
|
||||||
.map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?;
|
.map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?;
|
||||||
cwd.join(".sandcage.yml")
|
cwd.join(".sandcage.local.yml")
|
||||||
};
|
};
|
||||||
|
|
||||||
if !config_path.exists() {
|
if !config_path.exists() {
|
||||||
|
|||||||
Reference in New Issue
Block a user