🥇 export from upstream (0aed74f)
This commit is contained in:
Generated
+1
@@ -780,6 +780,7 @@ dependencies = [
|
||||
"dirs",
|
||||
"figment",
|
||||
"hex",
|
||||
"indexmap",
|
||||
"miette",
|
||||
"serde",
|
||||
"serde_yaml",
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
services:
|
||||
claude:
|
||||
image: sandcage:latest
|
||||
entrypoint: ["sandcage-claude-entrypoint"]
|
||||
working_dir: ${SANDCAGE_CONTAINER_DIR}
|
||||
user: "${SANDCAGE_UID}:${SANDCAGE_GID}"
|
||||
volumes:
|
||||
- ${SANDCAGE_HOME}/home:/home/agent
|
||||
- ${SANDCAGE_WORKSPACE}:${SANDCAGE_CONTAINER_DIR}
|
||||
environment:
|
||||
- HOME=/home/agent
|
||||
tty: true
|
||||
stdin_open: true
|
||||
|
||||
codex:
|
||||
image: sandcage:latest
|
||||
entrypoint: ["sandcage-codex-entrypoint"]
|
||||
working_dir: ${SANDCAGE_CONTAINER_DIR}
|
||||
user: "${SANDCAGE_UID}:${SANDCAGE_GID}"
|
||||
volumes:
|
||||
- ${SANDCAGE_HOME}/home:/home/agent
|
||||
- ${SANDCAGE_WORKSPACE}:${SANDCAGE_CONTAINER_DIR}
|
||||
environment:
|
||||
- HOME=/home/agent
|
||||
tty: true
|
||||
stdin_open: true
|
||||
|
||||
shell:
|
||||
image: sandcage:latest
|
||||
working_dir: ${SANDCAGE_CONTAINER_DIR}
|
||||
user: "${SANDCAGE_UID}:${SANDCAGE_GID}"
|
||||
volumes:
|
||||
- ${SANDCAGE_HOME}/home:/home/agent
|
||||
- ${SANDCAGE_WORKSPACE}:${SANDCAGE_CONTAINER_DIR}
|
||||
environment:
|
||||
- HOME=/home/agent
|
||||
tty: true
|
||||
stdin_open: true
|
||||
entrypoint: ["/bin/zsh"]
|
||||
@@ -17,6 +17,7 @@ which = "7"
|
||||
dirs = "6"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
tempfile = "3"
|
||||
dialoguer = "0.11"
|
||||
toml_edit = "0.22"
|
||||
|
||||
@@ -39,6 +39,12 @@ pub struct SshKeyEntry {
|
||||
pub identity_file: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct ServiceOverride {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raw deserialization type — captures unknown keys
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -70,6 +76,10 @@ struct RawConfig {
|
||||
ssh_mode: Option<String>,
|
||||
#[serde(default)]
|
||||
ssh_keys: Option<Vec<SshKeyEntry>>,
|
||||
#[serde(default)]
|
||||
services: Option<HashMap<String, ServiceOverride>>,
|
||||
#[serde(default)]
|
||||
default_services: Option<Vec<String>>,
|
||||
|
||||
/// Absorb everything else so we can warn about unknown fields.
|
||||
#[serde(flatten)]
|
||||
@@ -107,13 +117,17 @@ pub struct SandcageConfig {
|
||||
pub ssh_mode: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ssh_keys: Option<Vec<SshKeyEntry>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub services: Option<HashMap<String, ServiceOverride>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_services: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Known keys — used to filter the flattened `extra` map
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile", "dockerfiles", "trusted_projects", "container_workspace", "agent_args", "ssh_mode", "ssh_keys"];
|
||||
const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile", "dockerfiles", "trusted_projects", "container_workspace", "agent_args", "ssh_mode", "ssh_keys", "services", "default_services"];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation helpers
|
||||
@@ -168,6 +182,8 @@ fn from_raw(raw: RawConfig) -> SandcageConfig {
|
||||
agent_args: raw.agent_args,
|
||||
ssh_mode: raw.ssh_mode,
|
||||
ssh_keys: raw.ssh_keys,
|
||||
services: raw.services,
|
||||
default_services: raw.default_services,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,4 +658,64 @@ ssh_keys:
|
||||
let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path()), None).expect("resolve");
|
||||
assert_eq!(cfg.ssh_mode.as_deref(), Some("volume"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_services_override() {
|
||||
let yaml = r#"
|
||||
services:
|
||||
codex:
|
||||
enabled: false
|
||||
claude:
|
||||
enabled: true
|
||||
"#;
|
||||
let cfg = load_from_str(yaml).expect("parse services");
|
||||
let services = cfg.services.expect("services should be Some");
|
||||
assert_eq!(services.get("codex").unwrap().enabled, Some(false));
|
||||
assert_eq!(services.get("claude").unwrap().enabled, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_default_services() {
|
||||
let yaml = r#"
|
||||
default_services:
|
||||
- claude
|
||||
- shell
|
||||
"#;
|
||||
let cfg = load_from_str(yaml).expect("parse default_services");
|
||||
let defaults = cfg.default_services.expect("default_services should be Some");
|
||||
assert_eq!(defaults, vec!["claude", "shell"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn services_defaults_to_none() {
|
||||
let cfg = load_from_str("").expect("parse empty");
|
||||
assert!(cfg.services.is_none());
|
||||
assert!(cfg.default_services.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_services_project_overrides_global() {
|
||||
let global_toml = r#"
|
||||
[services.codex]
|
||||
enabled = true
|
||||
"#;
|
||||
let project_yaml = r#"
|
||||
services:
|
||||
codex:
|
||||
enabled: false
|
||||
"#;
|
||||
let mut global_tmp = NamedTempFile::new().expect("create global tmpfile");
|
||||
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()), None)
|
||||
.expect("resolve");
|
||||
let services = cfg.services.expect("services should be Some");
|
||||
assert_eq!(
|
||||
services.get("codex").unwrap().enabled,
|
||||
Some(false),
|
||||
"project should override global"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+114
-32
@@ -16,8 +16,6 @@ use crate::config::SandcageConfig;
|
||||
|
||||
pub const DOCKERFILE: &str = include_str!("../../../images/base/Dockerfile");
|
||||
|
||||
pub const COMPOSE_YAML: &str = include_str!("../../../compose/docker-compose.yml");
|
||||
|
||||
#[derive(Debug, Error, Diagnostic)]
|
||||
pub enum DockerError {
|
||||
#[error("docker executable not found in PATH")]
|
||||
@@ -80,6 +78,20 @@ pub enum DockerError {
|
||||
help("Run `sandcage build` to build the required images.")
|
||||
)]
|
||||
ImageNotFound { image: String },
|
||||
|
||||
#[error("Service '{service}' is not registered")]
|
||||
#[diagnostic(
|
||||
code(sandcage::docker::unknown_service),
|
||||
help("Available services: {available}")
|
||||
)]
|
||||
UnknownService { service: String, available: String },
|
||||
|
||||
#[error("Service '{service}' is disabled in config")]
|
||||
#[diagnostic(
|
||||
code(sandcage::docker::service_disabled),
|
||||
help("Enable it with: services.{service}.enabled: true")
|
||||
)]
|
||||
ServiceDisabled { service: String },
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, DockerError>;
|
||||
@@ -156,6 +168,37 @@ pub fn build_compose_env(workspace: &Path, config: &SandcageConfig) -> Result<Ha
|
||||
Ok(env)
|
||||
}
|
||||
|
||||
pub fn build_compose_context(workspace: &Path, config: &SandcageConfig) -> Result<crate::service::ComposeContext> {
|
||||
let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?;
|
||||
let sandcage_home = home.join(".sandcage");
|
||||
|
||||
let (uid, gid) = if cfg!(windows) {
|
||||
("1000".to_string(), "1000".to_string())
|
||||
} else {
|
||||
(id_flag("-u")?, id_flag("-g")?)
|
||||
};
|
||||
|
||||
let container_dir = match &config.container_workspace {
|
||||
Some(path) if path.starts_with('/') => path.clone(),
|
||||
Some(path) => {
|
||||
eprintln!(
|
||||
"sandcage: warning: container_workspace '{}' is not absolute, ignoring",
|
||||
path
|
||||
);
|
||||
default_container_dir(workspace)
|
||||
}
|
||||
None => default_container_dir(workspace),
|
||||
};
|
||||
|
||||
Ok(crate::service::ComposeContext {
|
||||
uid,
|
||||
gid,
|
||||
workspace: workspace.to_path_buf(),
|
||||
container_dir,
|
||||
sandcage_home,
|
||||
})
|
||||
}
|
||||
|
||||
fn default_container_dir(workspace: &Path) -> String {
|
||||
match workspace.file_name() {
|
||||
Some(name) => format!("/workspace/{}", name.to_string_lossy()),
|
||||
@@ -163,14 +206,14 @@ fn default_container_dir(workspace: &Path) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn write_compose_tempfile() -> Result<tempfile::NamedTempFile> {
|
||||
fn write_compose_tempfile(content: &str) -> Result<tempfile::NamedTempFile> {
|
||||
let mut tmp = tempfile::Builder::new()
|
||||
.prefix("sandcage-compose-")
|
||||
.suffix(".yml")
|
||||
.tempfile()
|
||||
.map_err(DockerError::TempfileFailed)?;
|
||||
|
||||
tmp.write_all(COMPOSE_YAML.as_bytes())
|
||||
tmp.write_all(content.as_bytes())
|
||||
.map_err(DockerError::TempfileFailed)?;
|
||||
tmp.flush().map_err(DockerError::TempfileFailed)?;
|
||||
|
||||
@@ -294,12 +337,24 @@ pub fn run_service(
|
||||
service: &str,
|
||||
workspace: &Path,
|
||||
config: &SandcageConfig,
|
||||
registry: &crate::service::registry::ServiceRegistry,
|
||||
shell_override: bool,
|
||||
extra_args: &[String],
|
||||
) -> Result<()> {
|
||||
let docker = require_docker()?;
|
||||
require_compose(&docker)?;
|
||||
|
||||
let svc = registry.get(service).ok_or_else(|| DockerError::UnknownService {
|
||||
service: service.to_string(),
|
||||
available: registry.names().collect::<Vec<_>>().join(", "),
|
||||
})?;
|
||||
|
||||
if !svc.enabled() {
|
||||
return Err(DockerError::ServiceDisabled {
|
||||
service: service.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let image = image_for_service(service);
|
||||
require_image(&docker, image)?;
|
||||
|
||||
@@ -314,20 +369,16 @@ pub fn run_service(
|
||||
}
|
||||
}
|
||||
|
||||
let compose_file = write_compose_tempfile()?;
|
||||
let ctx = build_compose_context(workspace, config)?;
|
||||
let compose_content = crate::service::compose::generate_compose(registry, &ctx);
|
||||
let compose_file = write_compose_tempfile(&compose_content)?;
|
||||
let compose_path = compose_file.path().to_string_lossy().into_owned();
|
||||
|
||||
let compose_env = build_compose_env(workspace, config)?;
|
||||
|
||||
let run_args = build_run_args(service, &compose_path, config, shell_override, extra_args);
|
||||
|
||||
let mut cmd = Command::new(&docker);
|
||||
cmd.args(&run_args);
|
||||
|
||||
// Inject compose environment variables
|
||||
cmd.envs(&compose_env);
|
||||
|
||||
// Inherit stdio for interactive use
|
||||
cmd.stdin(std::process::Stdio::inherit())
|
||||
.stdout(std::process::Stdio::inherit())
|
||||
.stderr(std::process::Stdio::inherit());
|
||||
@@ -412,6 +463,17 @@ fn build_one_image_from_content(docker: &Path, image: &str, dockerfile_content:
|
||||
std::fs::write(&dockerfile_path, dockerfile_content)
|
||||
.map_err(DockerError::DockerfileWriteFailed)?;
|
||||
|
||||
for svc in crate::service::builtin::all() {
|
||||
if let (Some(name), Some(script)) = (svc.entrypoint_name.as_ref(), svc.entrypoint_script.as_ref())
|
||||
&& name.starts_with("sandcage-")
|
||||
{
|
||||
let dir = tmp_dir.path().join("images").join(&svc.name);
|
||||
std::fs::create_dir_all(&dir).map_err(DockerError::TempDirFailed)?;
|
||||
std::fs::write(dir.join("entrypoint.sh"), script)
|
||||
.map_err(DockerError::DockerfileWriteFailed)?;
|
||||
}
|
||||
}
|
||||
|
||||
run_docker_build(docker, image, tmp_dir.path(), None, no_cache)
|
||||
}
|
||||
|
||||
@@ -483,7 +545,7 @@ fn read_dockerfile_at(path: &Path) -> Result<String> {
|
||||
/// * When `force` is `true`, every image is rebuilt regardless of the stored hash.
|
||||
/// * Otherwise, an image is skipped when its Dockerfile hash matches the stored value.
|
||||
/// * After a successful build the hash is updated in `~/.sandcage/.build-hashes`.
|
||||
pub fn build_images(force: bool) -> Result<()> {
|
||||
pub fn build_images(force: bool, service_filter: &[String]) -> Result<()> {
|
||||
let docker = require_docker()?;
|
||||
|
||||
let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?;
|
||||
@@ -494,6 +556,22 @@ pub fn build_images(force: bool) -> Result<()> {
|
||||
// Global overrides are always trusted.
|
||||
let global_cfg = crate::config::resolve_config(Some(&global_config), None, None)
|
||||
.unwrap_or_default();
|
||||
|
||||
// Validate service names if provided
|
||||
if !service_filter.is_empty() {
|
||||
let registry = crate::service::registry::build_default_registry(&global_cfg);
|
||||
let known: Vec<&str> = registry.names().collect();
|
||||
for name in service_filter {
|
||||
if !known.contains(&name.as_str()) {
|
||||
return Err(DockerError::UnknownService {
|
||||
service: name.clone(),
|
||||
available: known.join(", "),
|
||||
});
|
||||
}
|
||||
}
|
||||
eprintln!("sandcage: building for services: {}", service_filter.join(", "));
|
||||
}
|
||||
|
||||
let mut overrides = global_cfg.dockerfiles.unwrap_or_default();
|
||||
|
||||
// Project overrides require the project to be in trusted_projects.
|
||||
@@ -581,23 +659,26 @@ mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn compose_yaml_is_bundled() {
|
||||
assert!(
|
||||
COMPOSE_YAML.contains("services:"),
|
||||
"bundled compose YAML should contain 'services:'"
|
||||
);
|
||||
assert!(
|
||||
COMPOSE_YAML.contains("claude:"),
|
||||
"bundled compose YAML should define 'claude' service"
|
||||
);
|
||||
assert!(
|
||||
COMPOSE_YAML.contains("codex:"),
|
||||
"bundled compose YAML should define 'codex' service"
|
||||
);
|
||||
assert!(
|
||||
COMPOSE_YAML.contains("shell:"),
|
||||
"bundled compose YAML should define 'shell' service"
|
||||
);
|
||||
fn generated_compose_contains_all_services() {
|
||||
use crate::service::ComposeContext;
|
||||
use crate::service::registry::build_default_registry;
|
||||
use crate::service::compose::generate_compose;
|
||||
|
||||
let config = SandcageConfig::default();
|
||||
let registry = build_default_registry(&config);
|
||||
let ctx = ComposeContext {
|
||||
uid: "1000".to_string(),
|
||||
gid: "1000".to_string(),
|
||||
workspace: PathBuf::from("/tmp/test"),
|
||||
container_dir: "/workspace/test".to_string(),
|
||||
sandcage_home: PathBuf::from("/home/user/.sandcage"),
|
||||
};
|
||||
let yaml = generate_compose(®istry, &ctx);
|
||||
|
||||
assert!(yaml.contains("claude:"), "should contain claude service");
|
||||
assert!(yaml.contains("codex:"), "should contain codex service");
|
||||
assert!(yaml.contains("gemini:"), "should contain gemini service");
|
||||
assert!(yaml.contains("shell:"), "should contain shell service");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -649,9 +730,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn write_compose_tempfile_creates_valid_yaml() {
|
||||
let tmp = write_compose_tempfile().expect("write_compose_tempfile");
|
||||
let content = std::fs::read_to_string(tmp.path()).expect("read tempfile");
|
||||
assert_eq!(content, COMPOSE_YAML, "tempfile content should match bundled YAML");
|
||||
let content = "services:\n test:\n image: test:latest\n";
|
||||
let tmp = write_compose_tempfile(content).expect("write_compose_tempfile");
|
||||
let read_back = std::fs::read_to_string(tmp.path()).expect("read tempfile");
|
||||
assert_eq!(read_back, content, "tempfile content should match input");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -47,6 +47,10 @@ pub fn preseed_with_home(home: &Path) -> Result<()> {
|
||||
let codex_dir = agent_home.join(".codex");
|
||||
create_dir_all(&codex_dir)?;
|
||||
|
||||
// ~/.sandcage/home/.gemini/
|
||||
let gemini_dir = agent_home.join(".gemini");
|
||||
create_dir_all(&gemini_dir)?;
|
||||
|
||||
// 4. Seed Claude settings.json from the bundled template
|
||||
let settings_dest = claude_dir.join("settings.json");
|
||||
if !settings_dest.exists() {
|
||||
@@ -231,7 +235,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_claude_and_codex_dirs() {
|
||||
fn creates_agent_config_dirs() {
|
||||
let home = TempDir::new().expect("create tempdir");
|
||||
run(home.path()).expect("preseed");
|
||||
|
||||
@@ -243,6 +247,10 @@ mod tests {
|
||||
home.path().join(".sandcage/home/.codex").is_dir(),
|
||||
".codex dir should exist"
|
||||
);
|
||||
assert!(
|
||||
home.path().join(".sandcage/home/.gemini").is_dir(),
|
||||
".gemini dir should exist"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -320,6 +328,7 @@ mod tests {
|
||||
// All artefacts should still exist after two runs
|
||||
assert!(home.path().join(".sandcage/home/.claude").is_dir());
|
||||
assert!(home.path().join(".sandcage/home/.codex").is_dir());
|
||||
assert!(home.path().join(".sandcage/home/.gemini").is_dir());
|
||||
assert!(home.path().join(".sandcage/home/.claude/settings.json").exists());
|
||||
assert!(home.path().join(".sandcage/home/.justfile").exists());
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod config;
|
||||
pub mod docker;
|
||||
pub mod init;
|
||||
pub mod service;
|
||||
pub mod setup;
|
||||
pub mod ssh_config;
|
||||
pub mod workspace;
|
||||
|
||||
@@ -6,6 +6,7 @@ use thiserror::Error;
|
||||
use sandcage::config;
|
||||
use sandcage::docker;
|
||||
use sandcage::init;
|
||||
use sandcage::service;
|
||||
use sandcage::setup;
|
||||
use sandcage::workspace;
|
||||
|
||||
@@ -47,17 +48,34 @@ enum Commands {
|
||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||
agent_args: Vec<String>,
|
||||
},
|
||||
/// Run Gemini CLI agent in a sandboxed container
|
||||
Gemini {
|
||||
/// Path to the project directory (defaults to current directory)
|
||||
#[arg(long, short)]
|
||||
path: Option<PathBuf>,
|
||||
|
||||
/// Drop into a shell instead of launching the agent
|
||||
#[arg(long)]
|
||||
shell: bool,
|
||||
|
||||
/// Arguments forwarded to the agent inside the container
|
||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||
agent_args: Vec<String>,
|
||||
},
|
||||
/// Interactive shell with the same environment
|
||||
Shell {
|
||||
/// Path to the project directory (defaults to current directory)
|
||||
#[arg(long, short)]
|
||||
path: Option<PathBuf>,
|
||||
},
|
||||
/// Build all container images
|
||||
/// Build container images
|
||||
Build {
|
||||
/// Force rebuild even if images are up to date
|
||||
#[arg(long, short)]
|
||||
force: bool,
|
||||
|
||||
/// Services to build (default: all enabled services)
|
||||
services: Vec<String>,
|
||||
},
|
||||
/// Initialize a .sandcage.yml for a project
|
||||
Init,
|
||||
@@ -113,7 +131,7 @@ enum AppError {
|
||||
Setup(#[from] setup::SetupError),
|
||||
}
|
||||
|
||||
fn run_agent(service: &str, path: Option<PathBuf>, shell_override: bool, agent_args: Vec<String>) -> std::result::Result<(), AppError> {
|
||||
fn run_service(service: &str, path: Option<PathBuf>, shell_override: bool, agent_args: Vec<String>) -> std::result::Result<(), AppError> {
|
||||
let workspace = workspace::resolve_workspace(path.as_deref())?;
|
||||
eprintln!("sandcage: workspace \u{2192} {}", workspace.display());
|
||||
|
||||
@@ -129,8 +147,10 @@ fn run_agent(service: &str, path: Option<PathBuf>, shell_override: bool, agent_a
|
||||
Some(&local_config),
|
||||
)?;
|
||||
|
||||
let registry = service::registry::build_default_registry(&cfg);
|
||||
|
||||
eprintln!("sandcage: service \u{2192} {service}");
|
||||
docker::run_service(service, &workspace, &cfg, shell_override, &agent_args)?;
|
||||
docker::run_service(service, &workspace, &cfg, ®istry, shell_override, &agent_args)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -140,14 +160,17 @@ fn main() -> miette::Result<()> {
|
||||
|
||||
match cli.command {
|
||||
Commands::Claude { path, shell, agent_args } => {
|
||||
run_agent("claude", path, shell, agent_args)?
|
||||
run_service("claude", path, shell, agent_args)?
|
||||
}
|
||||
Commands::Codex { path, shell, agent_args } => {
|
||||
run_agent("codex", path, shell, agent_args)?
|
||||
run_service("codex", path, shell, agent_args)?
|
||||
}
|
||||
Commands::Shell { path } => run_agent("shell", path, false, vec![])?,
|
||||
Commands::Build { force } => {
|
||||
docker::build_images(force)?;
|
||||
Commands::Gemini { path, shell, agent_args } => {
|
||||
run_service("gemini", path, shell, agent_args)?
|
||||
}
|
||||
Commands::Shell { path } => run_service("shell", path, false, vec![])?,
|
||||
Commands::Build { force, services } => {
|
||||
docker::build_images(force, &services)?;
|
||||
}
|
||||
Commands::Init => {
|
||||
let workspace = workspace::resolve_workspace(None)?;
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
use super::ServiceDef;
|
||||
|
||||
pub const CLAUDE_ENTRYPOINT: &str = r#"#!/bin/sh
|
||||
set -e
|
||||
CLAUDE_BIN="$HOME/.local/bin/claude"
|
||||
if [ ! -x "$CLAUDE_BIN" ]; then
|
||||
echo "sandcage: installing Claude Code..." >&2
|
||||
curl -fsSL https://claude.ai/install.sh | bash >&2
|
||||
fi
|
||||
exec "$CLAUDE_BIN" "$@"
|
||||
"#;
|
||||
|
||||
pub const CODEX_ENTRYPOINT: &str = r#"#!/bin/sh
|
||||
set -e
|
||||
CODEX_BIN="$HOME/.local/bin/codex"
|
||||
if [ ! -x "$CODEX_BIN" ]; then
|
||||
echo "sandcage: installing Codex..." >&2
|
||||
arch=$(uname -m)
|
||||
case "$arch" in
|
||||
x86_64) target="x86_64-unknown-linux-musl" ;;
|
||||
aarch64) target="aarch64-unknown-linux-musl" ;;
|
||||
*) echo "sandcage: unsupported arch: $arch" >&2; exit 1 ;;
|
||||
esac
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
curl -fsSL "https://github.com/openai/codex/releases/latest/download/codex-${target}.tar.gz" \
|
||||
| tar xz -C "$HOME/.local/bin"
|
||||
mv "$HOME/.local/bin/codex-${target}" "$CODEX_BIN"
|
||||
fi
|
||||
exec "$CODEX_BIN" "$@"
|
||||
"#;
|
||||
|
||||
pub const GEMINI_ENTRYPOINT: &str = r#"#!/bin/sh
|
||||
set -e
|
||||
GEMINI_BIN="$HOME/.local/bin/gemini"
|
||||
if [ ! -x "$GEMINI_BIN" ]; then
|
||||
echo "sandcage: installing Gemini CLI..." >&2
|
||||
npm install -g @anthropic-ai/gemini-cli >&2
|
||||
fi
|
||||
exec "$GEMINI_BIN" "$@"
|
||||
"#;
|
||||
|
||||
pub fn claude() -> ServiceDef {
|
||||
ServiceDef {
|
||||
name: "claude".to_string(),
|
||||
description: "Claude Code agent".to_string(),
|
||||
enabled: true,
|
||||
entrypoint_name: Some("sandcage-claude-entrypoint".to_string()),
|
||||
entrypoint_script: Some(CLAUDE_ENTRYPOINT.to_string()),
|
||||
config_dir: Some(".claude".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn codex() -> ServiceDef {
|
||||
ServiceDef {
|
||||
name: "codex".to_string(),
|
||||
description: "Codex agent".to_string(),
|
||||
enabled: true,
|
||||
entrypoint_name: Some("sandcage-codex-entrypoint".to_string()),
|
||||
entrypoint_script: Some(CODEX_ENTRYPOINT.to_string()),
|
||||
config_dir: Some(".codex".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gemini() -> ServiceDef {
|
||||
ServiceDef {
|
||||
name: "gemini".to_string(),
|
||||
description: "Gemini CLI agent".to_string(),
|
||||
enabled: true,
|
||||
entrypoint_name: Some("sandcage-gemini-entrypoint".to_string()),
|
||||
entrypoint_script: Some(GEMINI_ENTRYPOINT.to_string()),
|
||||
config_dir: Some(".gemini".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shell() -> ServiceDef {
|
||||
ServiceDef {
|
||||
name: "shell".to_string(),
|
||||
description: "Interactive shell".to_string(),
|
||||
enabled: true,
|
||||
entrypoint_name: Some("/bin/zsh".to_string()),
|
||||
entrypoint_script: None,
|
||||
config_dir: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all() -> Vec<ServiceDef> {
|
||||
vec![claude(), codex(), gemini(), shell()]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::service::Service;
|
||||
|
||||
#[test]
|
||||
fn all_builtins_are_enabled_by_default() {
|
||||
for svc in all() {
|
||||
assert!(svc.enabled(), "{} should be enabled by default", svc.name());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_builtins_have_unique_names() {
|
||||
let services = all();
|
||||
let mut names: Vec<&str> = services.iter().map(|s| s.name()).collect();
|
||||
let count = names.len();
|
||||
names.dedup();
|
||||
assert_eq!(names.len(), count, "service names must be unique");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_services_have_entrypoint_scripts() {
|
||||
for svc in [claude(), codex(), gemini()] {
|
||||
assert!(
|
||||
svc.entrypoint_script().is_some(),
|
||||
"{} should have an entrypoint script",
|
||||
svc.name()
|
||||
);
|
||||
assert!(
|
||||
svc.entrypoint_script().unwrap().starts_with("#!/bin/sh"),
|
||||
"{} entrypoint should start with shebang",
|
||||
svc.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_has_no_entrypoint_script() {
|
||||
let svc = shell();
|
||||
assert!(svc.entrypoint_script().is_none());
|
||||
assert_eq!(svc.entrypoint_name(), Some("/bin/zsh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_services_have_config_dirs() {
|
||||
assert_eq!(claude().config_dir(), Some(".claude"));
|
||||
assert_eq!(codex().config_dir(), Some(".codex"));
|
||||
assert_eq!(gemini().config_dir(), Some(".gemini"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_has_no_config_dir() {
|
||||
assert_eq!(shell().config_dir(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_entrypoint_installs_from_claude_ai() {
|
||||
assert!(CLAUDE_ENTRYPOINT.contains("claude.ai/install.sh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_entrypoint_handles_arch_detection() {
|
||||
assert!(CODEX_ENTRYPOINT.contains("uname -m"));
|
||||
assert!(CODEX_ENTRYPOINT.contains("x86_64"));
|
||||
assert!(CODEX_ENTRYPOINT.contains("aarch64"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
use super::registry::ServiceRegistry;
|
||||
use super::ComposeContext;
|
||||
|
||||
pub fn generate_compose(registry: &ServiceRegistry, ctx: &ComposeContext) -> String {
|
||||
let mut services = serde_yaml::Mapping::new();
|
||||
|
||||
for svc in registry.enabled() {
|
||||
let def = svc.compose_service(ctx);
|
||||
let value = serde_yaml::to_value(&def).expect("ComposeServiceDef must serialize");
|
||||
services.insert(
|
||||
serde_yaml::Value::String(svc.name().to_string()),
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
let mut doc = serde_yaml::Mapping::new();
|
||||
doc.insert(
|
||||
serde_yaml::Value::String("services".to_string()),
|
||||
serde_yaml::Value::Mapping(services),
|
||||
);
|
||||
|
||||
serde_yaml::to_string(&serde_yaml::Value::Mapping(doc))
|
||||
.expect("compose doc must serialize")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::service::builtin;
|
||||
use crate::service::ServiceDef;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn test_context() -> ComposeContext {
|
||||
ComposeContext {
|
||||
uid: "1000".to_string(),
|
||||
gid: "1000".to_string(),
|
||||
workspace: PathBuf::from("/home/user/projects/my-app"),
|
||||
container_dir: "/workspace/my-app".to_string(),
|
||||
sandcage_home: PathBuf::from("/home/user/.sandcage"),
|
||||
}
|
||||
}
|
||||
|
||||
fn registry_with(services: Vec<ServiceDef>) -> ServiceRegistry {
|
||||
let mut reg = ServiceRegistry::new();
|
||||
for svc in services {
|
||||
reg.register(Box::new(svc));
|
||||
}
|
||||
reg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_valid_yaml() {
|
||||
let reg = registry_with(vec![builtin::claude(), builtin::shell()]);
|
||||
let yaml = generate_compose(®, &test_context());
|
||||
|
||||
let parsed: serde_yaml::Value =
|
||||
serde_yaml::from_str(&yaml).expect("output must be valid YAML");
|
||||
assert!(parsed.as_mapping().unwrap().contains_key("services"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_only_enabled_services() {
|
||||
let mut codex = builtin::codex();
|
||||
codex.enabled = false;
|
||||
|
||||
let reg = registry_with(vec![builtin::claude(), codex, builtin::shell()]);
|
||||
let yaml = generate_compose(®, &test_context());
|
||||
|
||||
assert!(yaml.contains("claude:"), "claude should be present");
|
||||
assert!(yaml.contains("shell:"), "shell should be present");
|
||||
assert!(!yaml.contains("codex:"), "disabled codex should be absent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_registry_produces_empty_services() {
|
||||
let reg = ServiceRegistry::new();
|
||||
let yaml = generate_compose(®, &test_context());
|
||||
|
||||
let parsed: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
|
||||
let services = parsed.as_mapping().unwrap().get("services").unwrap();
|
||||
assert!(
|
||||
services.as_mapping().unwrap().is_empty(),
|
||||
"services should be empty mapping"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_has_correct_image() {
|
||||
let reg = registry_with(vec![builtin::claude()]);
|
||||
let yaml = generate_compose(®, &test_context());
|
||||
assert!(yaml.contains("sandcage:latest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_has_correct_entrypoint() {
|
||||
let reg = registry_with(vec![builtin::claude()]);
|
||||
let yaml = generate_compose(®, &test_context());
|
||||
assert!(yaml.contains("sandcage-claude-entrypoint"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_has_correct_working_dir() {
|
||||
let reg = registry_with(vec![builtin::claude()]);
|
||||
let yaml = generate_compose(®, &test_context());
|
||||
assert!(yaml.contains("/workspace/my-app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_has_home_volume() {
|
||||
let reg = registry_with(vec![builtin::claude()]);
|
||||
let yaml = generate_compose(®, &test_context());
|
||||
assert!(yaml.contains("/home/user/.sandcage/home:/home/agent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_four_builtins_generate_successfully() {
|
||||
let reg = registry_with(builtin::all());
|
||||
let yaml = generate_compose(®, &test_context());
|
||||
|
||||
for name in ["claude", "codex", "gemini", "shell"] {
|
||||
assert!(
|
||||
yaml.contains(&format!("{name}:")),
|
||||
"compose should contain {name} service"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backward_compat_claude_codex_shell_match_old_structure() {
|
||||
let reg = registry_with(vec![
|
||||
builtin::claude(),
|
||||
builtin::codex(),
|
||||
builtin::shell(),
|
||||
]);
|
||||
let yaml = generate_compose(®, &test_context());
|
||||
let parsed: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
|
||||
let services = parsed["services"].as_mapping().unwrap();
|
||||
|
||||
for name in ["claude", "codex", "shell"] {
|
||||
let svc = &services[&serde_yaml::Value::String(name.to_string())];
|
||||
assert_eq!(
|
||||
svc["image"].as_str().unwrap(),
|
||||
"sandcage:latest",
|
||||
"{name}: image"
|
||||
);
|
||||
assert!(svc["tty"].as_bool().unwrap(), "{name}: tty");
|
||||
assert!(svc["stdin_open"].as_bool().unwrap(), "{name}: stdin_open");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
pub mod builtin;
|
||||
pub mod compose;
|
||||
pub mod registry;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compose output types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ComposeContext {
|
||||
pub uid: String,
|
||||
pub gid: String,
|
||||
pub workspace: PathBuf,
|
||||
pub container_dir: String,
|
||||
pub sandcage_home: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ComposeServiceDef {
|
||||
pub image: String,
|
||||
pub entrypoint: Vec<String>,
|
||||
pub working_dir: String,
|
||||
pub user: String,
|
||||
pub volumes: Vec<String>,
|
||||
pub environment: Vec<String>,
|
||||
pub tty: bool,
|
||||
pub stdin_open: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service trait
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub trait Service: std::fmt::Debug + Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
fn description(&self) -> &str;
|
||||
fn enabled(&self) -> bool;
|
||||
fn set_enabled(&mut self, enabled: bool);
|
||||
fn entrypoint_name(&self) -> Option<&str>;
|
||||
fn entrypoint_script(&self) -> Option<&str>;
|
||||
fn config_dir(&self) -> Option<&str>;
|
||||
fn compose_service(&self, ctx: &ComposeContext) -> ComposeServiceDef;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ServiceDef — standard implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServiceDef {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub enabled: bool,
|
||||
pub entrypoint_name: Option<String>,
|
||||
pub entrypoint_script: Option<String>,
|
||||
pub config_dir: Option<String>,
|
||||
}
|
||||
|
||||
impl Service for ServiceDef {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
&self.description
|
||||
}
|
||||
|
||||
fn enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
fn set_enabled(&mut self, enabled: bool) {
|
||||
self.enabled = enabled;
|
||||
}
|
||||
|
||||
fn entrypoint_name(&self) -> Option<&str> {
|
||||
self.entrypoint_name.as_deref()
|
||||
}
|
||||
|
||||
fn entrypoint_script(&self) -> Option<&str> {
|
||||
self.entrypoint_script.as_deref()
|
||||
}
|
||||
|
||||
fn config_dir(&self) -> Option<&str> {
|
||||
self.config_dir.as_deref()
|
||||
}
|
||||
|
||||
fn compose_service(&self, ctx: &ComposeContext) -> ComposeServiceDef {
|
||||
let entrypoint = match self.entrypoint_name.as_deref() {
|
||||
Some(name) => vec![name.to_string()],
|
||||
None => vec!["/bin/zsh".to_string()],
|
||||
};
|
||||
|
||||
ComposeServiceDef {
|
||||
image: "sandcage:latest".to_string(),
|
||||
entrypoint,
|
||||
working_dir: ctx.container_dir.clone(),
|
||||
user: format!("{}:{}", ctx.uid, ctx.gid),
|
||||
volumes: vec![
|
||||
format!("{}/home:/home/agent", ctx.sandcage_home.display()),
|
||||
format!(
|
||||
"{}:{}",
|
||||
ctx.workspace.display(),
|
||||
ctx.container_dir
|
||||
),
|
||||
],
|
||||
environment: vec!["HOME=/home/agent".to_string()],
|
||||
tty: true,
|
||||
stdin_open: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_context() -> ComposeContext {
|
||||
ComposeContext {
|
||||
uid: "1000".to_string(),
|
||||
gid: "1000".to_string(),
|
||||
workspace: PathBuf::from("/home/user/projects/my-app"),
|
||||
container_dir: "/workspace/my-app".to_string(),
|
||||
sandcage_home: PathBuf::from("/home/user/.sandcage"),
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_service() -> ServiceDef {
|
||||
ServiceDef {
|
||||
name: "test-agent".to_string(),
|
||||
description: "A test agent".to_string(),
|
||||
enabled: true,
|
||||
entrypoint_name: Some("sandcage-test-entrypoint".to_string()),
|
||||
entrypoint_script: Some("#!/bin/sh\nexec echo hello".to_string()),
|
||||
config_dir: Some(".test-agent".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_def_trait_accessors() {
|
||||
let svc = agent_service();
|
||||
assert_eq!(svc.name(), "test-agent");
|
||||
assert_eq!(svc.description(), "A test agent");
|
||||
assert!(svc.enabled());
|
||||
assert_eq!(svc.entrypoint_name(), Some("sandcage-test-entrypoint"));
|
||||
assert!(svc.entrypoint_script().is_some());
|
||||
assert_eq!(svc.config_dir(), Some(".test-agent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_def_set_enabled() {
|
||||
let mut svc = agent_service();
|
||||
assert!(svc.enabled());
|
||||
svc.set_enabled(false);
|
||||
assert!(!svc.enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_service_has_correct_image() {
|
||||
let svc = agent_service();
|
||||
let ctx = test_context();
|
||||
let compose = svc.compose_service(&ctx);
|
||||
assert_eq!(compose.image, "sandcage:latest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_service_uses_entrypoint_name() {
|
||||
let svc = agent_service();
|
||||
let ctx = test_context();
|
||||
let compose = svc.compose_service(&ctx);
|
||||
assert_eq!(compose.entrypoint, vec!["sandcage-test-entrypoint"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_service_shell_uses_bin_zsh_when_no_entrypoint() {
|
||||
let svc = ServiceDef {
|
||||
entrypoint_name: None,
|
||||
..agent_service()
|
||||
};
|
||||
let ctx = test_context();
|
||||
let compose = svc.compose_service(&ctx);
|
||||
assert_eq!(compose.entrypoint, vec!["/bin/zsh"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_service_has_correct_user() {
|
||||
let svc = agent_service();
|
||||
let ctx = test_context();
|
||||
let compose = svc.compose_service(&ctx);
|
||||
assert_eq!(compose.user, "1000:1000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_service_has_home_and_workspace_volumes() {
|
||||
let svc = agent_service();
|
||||
let ctx = test_context();
|
||||
let compose = svc.compose_service(&ctx);
|
||||
assert_eq!(compose.volumes.len(), 2);
|
||||
assert!(compose.volumes[0].contains("/home:/home/agent"));
|
||||
assert!(compose.volumes[1].contains("/workspace/my-app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_service_has_tty_and_stdin() {
|
||||
let svc = agent_service();
|
||||
let ctx = test_context();
|
||||
let compose = svc.compose_service(&ctx);
|
||||
assert!(compose.tty);
|
||||
assert!(compose.stdin_open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_service_uses_explicit_entrypoint() {
|
||||
let svc = ServiceDef {
|
||||
name: "shell".to_string(),
|
||||
description: "Interactive shell".to_string(),
|
||||
enabled: true,
|
||||
entrypoint_name: Some("/bin/zsh".to_string()),
|
||||
entrypoint_script: None,
|
||||
config_dir: None,
|
||||
};
|
||||
let ctx = test_context();
|
||||
let compose = svc.compose_service(&ctx);
|
||||
assert_eq!(compose.entrypoint, vec!["/bin/zsh"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
use indexmap::IndexMap;
|
||||
|
||||
use super::Service;
|
||||
|
||||
pub struct ServiceRegistry {
|
||||
services: IndexMap<String, Box<dyn Service>>,
|
||||
}
|
||||
|
||||
impl Default for ServiceRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ServiceRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
services: IndexMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(&mut self, service: Box<dyn Service>) {
|
||||
self.services.insert(service.name().to_string(), service);
|
||||
}
|
||||
|
||||
pub fn get(&self, name: &str) -> Option<&dyn Service> {
|
||||
self.services.get(name).map(|s| s.as_ref())
|
||||
}
|
||||
|
||||
pub fn get_mut(&mut self, name: &str) -> Option<&mut Box<dyn Service>> {
|
||||
self.services.get_mut(name)
|
||||
}
|
||||
|
||||
pub fn enabled(&self) -> impl Iterator<Item = &dyn Service> {
|
||||
self.services.values().filter(|s| s.enabled()).map(|s| s.as_ref())
|
||||
}
|
||||
|
||||
pub fn all(&self) -> impl Iterator<Item = &dyn Service> {
|
||||
self.services.values().map(|s| s.as_ref())
|
||||
}
|
||||
|
||||
pub fn names(&self) -> impl Iterator<Item = &str> {
|
||||
self.services.keys().map(|k| k.as_str())
|
||||
}
|
||||
|
||||
pub fn enabled_names(&self) -> Vec<&str> {
|
||||
self.enabled().map(|s| s.name()).collect()
|
||||
}
|
||||
|
||||
pub fn apply_overrides(&mut self, config: &crate::config::SandcageConfig) {
|
||||
if let Some(ref overrides) = config.services {
|
||||
for (name, ovr) in overrides {
|
||||
if let Some(svc) = self.get_mut(name)
|
||||
&& let Some(enabled) = ovr.enabled
|
||||
{
|
||||
svc.set_enabled(enabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ServiceRegistry {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ServiceRegistry")
|
||||
.field("services", &self.services.keys().collect::<Vec<_>>())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_default_registry(config: &crate::config::SandcageConfig) -> ServiceRegistry {
|
||||
let mut reg = ServiceRegistry::new();
|
||||
for svc in super::builtin::all() {
|
||||
reg.register(Box::new(svc));
|
||||
}
|
||||
reg.apply_overrides(config);
|
||||
reg
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::service::ServiceDef;
|
||||
|
||||
fn make_service(name: &str, enabled: bool) -> Box<dyn Service> {
|
||||
Box::new(ServiceDef {
|
||||
name: name.to_string(),
|
||||
description: format!("{name} service"),
|
||||
enabled,
|
||||
entrypoint_name: Some(format!("sandcage-{name}-entrypoint")),
|
||||
entrypoint_script: Some("#!/bin/sh\nexec echo test".to_string()),
|
||||
config_dir: Some(format!(".{name}")),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_and_get() {
|
||||
let mut reg = ServiceRegistry::new();
|
||||
reg.register(make_service("claude", true));
|
||||
assert!(reg.get("claude").is_some());
|
||||
assert!(reg.get("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_filters_disabled_services() {
|
||||
let mut reg = ServiceRegistry::new();
|
||||
reg.register(make_service("claude", true));
|
||||
reg.register(make_service("codex", false));
|
||||
reg.register(make_service("shell", true));
|
||||
|
||||
let enabled: Vec<&str> = reg.enabled().map(|s| s.name()).collect();
|
||||
assert_eq!(enabled, vec!["claude", "shell"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_returns_every_service() {
|
||||
let mut reg = ServiceRegistry::new();
|
||||
reg.register(make_service("claude", true));
|
||||
reg.register(make_service("codex", false));
|
||||
|
||||
let all: Vec<&str> = reg.all().map(|s| s.name()).collect();
|
||||
assert_eq!(all, vec!["claude", "codex"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_returns_all_keys() {
|
||||
let mut reg = ServiceRegistry::new();
|
||||
reg.register(make_service("claude", true));
|
||||
reg.register(make_service("codex", true));
|
||||
|
||||
let names: Vec<&str> = reg.names().collect();
|
||||
assert_eq!(names, vec!["claude", "codex"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_insertion_order() {
|
||||
let mut reg = ServiceRegistry::new();
|
||||
reg.register(make_service("shell", true));
|
||||
reg.register(make_service("claude", true));
|
||||
reg.register(make_service("codex", true));
|
||||
|
||||
let names: Vec<&str> = reg.names().collect();
|
||||
assert_eq!(names, vec!["shell", "claude", "codex"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_mut_can_disable_service() {
|
||||
let mut reg = ServiceRegistry::new();
|
||||
reg.register(make_service("claude", true));
|
||||
|
||||
assert!(reg.get("claude").unwrap().enabled());
|
||||
reg.get_mut("claude").unwrap().set_enabled(false);
|
||||
assert!(!reg.get("claude").unwrap().enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_names_convenience() {
|
||||
let mut reg = ServiceRegistry::new();
|
||||
reg.register(make_service("claude", true));
|
||||
reg.register(make_service("codex", false));
|
||||
reg.register(make_service("shell", true));
|
||||
|
||||
assert_eq!(reg.enabled_names(), vec!["claude", "shell"]);
|
||||
}
|
||||
|
||||
use crate::config::{SandcageConfig, ServiceOverride};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn apply_overrides_disables_service() {
|
||||
let mut reg = ServiceRegistry::new();
|
||||
reg.register(make_service("claude", true));
|
||||
reg.register(make_service("codex", true));
|
||||
|
||||
let mut overrides = HashMap::new();
|
||||
overrides.insert("codex".to_string(), ServiceOverride { enabled: Some(false) });
|
||||
let config = SandcageConfig {
|
||||
services: Some(overrides),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
reg.apply_overrides(&config);
|
||||
assert!(reg.get("claude").unwrap().enabled());
|
||||
assert!(!reg.get("codex").unwrap().enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_overrides_ignores_unknown_services() {
|
||||
let mut reg = ServiceRegistry::new();
|
||||
reg.register(make_service("claude", true));
|
||||
|
||||
let mut overrides = HashMap::new();
|
||||
overrides.insert("nonexistent".to_string(), ServiceOverride { enabled: Some(false) });
|
||||
let config = SandcageConfig {
|
||||
services: Some(overrides),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
reg.apply_overrides(&config);
|
||||
assert!(reg.get("claude").unwrap().enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_default_registry_has_all_builtins() {
|
||||
let config = SandcageConfig::default();
|
||||
let reg = build_default_registry(&config);
|
||||
|
||||
for name in ["claude", "codex", "gemini", "shell"] {
|
||||
assert!(
|
||||
reg.get(name).is_some(),
|
||||
"{name} should be in default registry"
|
||||
);
|
||||
assert!(
|
||||
reg.get(name).unwrap().enabled(),
|
||||
"{name} should be enabled by default"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_default_registry_applies_overrides() {
|
||||
let mut overrides = HashMap::new();
|
||||
overrides.insert("codex".to_string(), ServiceOverride { enabled: Some(false) });
|
||||
overrides.insert("gemini".to_string(), ServiceOverride { enabled: Some(false) });
|
||||
|
||||
let config = SandcageConfig {
|
||||
services: Some(overrides),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let reg = build_default_registry(&config);
|
||||
assert!(reg.get("claude").unwrap().enabled());
|
||||
assert!(!reg.get("codex").unwrap().enabled());
|
||||
assert!(!reg.get("gemini").unwrap().enabled());
|
||||
assert!(reg.get("shell").unwrap().enabled());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user