🥇 export from upstream (ce9e2c1)
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
# Sandcage project configuration
|
||||
# Docs: https://github.com/user/sandcage
|
||||
|
||||
# Detected ecosystem: rust
|
||||
|
||||
env:
|
||||
# EXAMPLE_VAR: value
|
||||
|
||||
toolchains:
|
||||
rust: "stable"
|
||||
|
||||
packages:
|
||||
- ripgrep
|
||||
- fd-find
|
||||
|
||||
# mounts:
|
||||
# - ~/.ssh:/home/agent/.ssh:ro
|
||||
# - ~/.gitconfig:/home/agent/.gitconfig:ro
|
||||
|
||||
agent_args:
|
||||
claude:
|
||||
- "--dangerously-skip-permissions"
|
||||
|
||||
# shell: zsh
|
||||
|
||||
# container_workspace: /workspace/my-project
|
||||
Generated
+149
-10
@@ -62,7 +62,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -73,7 +73,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -220,6 +220,19 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "console"
|
||||
version = "0.15.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
|
||||
dependencies = [
|
||||
"encode_unicode",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"unicode-width 0.2.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -239,6 +252,19 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dialoguer"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de"
|
||||
dependencies = [
|
||||
"console",
|
||||
"shell-words",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "difflib"
|
||||
version = "0.4.0"
|
||||
@@ -273,7 +299,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -282,6 +308,12 @@ version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||
|
||||
[[package]]
|
||||
name = "encode_unicode"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||
|
||||
[[package]]
|
||||
name = "env_home"
|
||||
version = "0.1.0"
|
||||
@@ -301,7 +333,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -682,7 +714,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -730,7 +762,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -744,6 +776,7 @@ name = "sandcage"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"dialoguer",
|
||||
"dirs",
|
||||
"figment",
|
||||
"hex",
|
||||
@@ -752,7 +785,8 @@ dependencies = [
|
||||
"serde_yaml",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"thiserror 2.0.18",
|
||||
"toml_edit",
|
||||
"which",
|
||||
]
|
||||
|
||||
@@ -848,6 +882,12 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shell-words"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -896,7 +936,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -906,7 +946,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -925,13 +965,33 @@ dependencies = [
|
||||
"unicode-width 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
"thiserror-impl 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1134,6 +1194,15 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -1143,6 +1212,70 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
@@ -1258,6 +1391,12 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
||||
@@ -4,10 +4,8 @@ services:
|
||||
working_dir: ${SANDCAGE_CONTAINER_DIR}
|
||||
user: "${SANDCAGE_UID}:${SANDCAGE_GID}"
|
||||
volumes:
|
||||
- ${SANDCAGE_HOME}/home:/home/agent
|
||||
- ${SANDCAGE_WORKSPACE}:${SANDCAGE_CONTAINER_DIR}
|
||||
- ${SANDCAGE_HOME}/.claude:/home/agent/.claude
|
||||
- ${SANDCAGE_HOME}/.claude.json:/home/agent/.claude.json
|
||||
- ${SANDCAGE_GLOBAL_JUSTFILE}:/home/agent/.justfile:ro
|
||||
environment:
|
||||
- HOME=/home/agent
|
||||
tty: true
|
||||
@@ -18,9 +16,8 @@ services:
|
||||
working_dir: ${SANDCAGE_CONTAINER_DIR}
|
||||
user: "${SANDCAGE_UID}:${SANDCAGE_GID}"
|
||||
volumes:
|
||||
- ${SANDCAGE_HOME}/home:/home/agent
|
||||
- ${SANDCAGE_WORKSPACE}:${SANDCAGE_CONTAINER_DIR}
|
||||
- ${SANDCAGE_HOME}/.codex:/home/agent/.codex
|
||||
- ${SANDCAGE_GLOBAL_JUSTFILE}:/home/agent/.justfile:ro
|
||||
environment:
|
||||
- HOME=/home/agent
|
||||
tty: true
|
||||
@@ -31,11 +28,8 @@ services:
|
||||
working_dir: ${SANDCAGE_CONTAINER_DIR}
|
||||
user: "${SANDCAGE_UID}:${SANDCAGE_GID}"
|
||||
volumes:
|
||||
- ${SANDCAGE_HOME}/home:/home/agent
|
||||
- ${SANDCAGE_WORKSPACE}:${SANDCAGE_CONTAINER_DIR}
|
||||
- ${SANDCAGE_HOME}/.claude:/home/agent/.claude
|
||||
- ${SANDCAGE_HOME}/.claude.json:/home/agent/.claude.json
|
||||
- ${SANDCAGE_HOME}/.codex:/home/agent/.codex
|
||||
- ${SANDCAGE_GLOBAL_JUSTFILE}:/home/agent/.justfile:ro
|
||||
environment:
|
||||
- HOME=/home/agent
|
||||
tty: true
|
||||
|
||||
@@ -18,3 +18,5 @@ dirs = "6"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
tempfile = "3"
|
||||
dialoguer = "0.11"
|
||||
toml_edit = "0.22"
|
||||
|
||||
@@ -54,6 +54,8 @@ struct RawConfig {
|
||||
trusted_projects: Option<Vec<PathBuf>>,
|
||||
#[serde(default)]
|
||||
container_workspace: Option<String>,
|
||||
#[serde(default)]
|
||||
agent_args: Option<HashMap<String, Vec<String>>>,
|
||||
|
||||
/// Absorb everything else so we can warn about unknown fields.
|
||||
#[serde(flatten)]
|
||||
@@ -85,13 +87,15 @@ pub struct SandcageConfig {
|
||||
pub trusted_projects: Option<Vec<PathBuf>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub container_workspace: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub agent_args: Option<HashMap<String, 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"];
|
||||
const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile", "dockerfiles", "trusted_projects", "container_workspace", "agent_args"];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation helpers
|
||||
@@ -143,6 +147,7 @@ fn from_raw(raw: RawConfig) -> SandcageConfig {
|
||||
dockerfiles: raw.dockerfiles,
|
||||
trusted_projects: raw.trusted_projects,
|
||||
container_workspace: raw.container_workspace,
|
||||
agent_args: raw.agent_args,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,4 +524,50 @@ EDITOR = "vim"
|
||||
let cfg = load_from_str("").expect("parse empty");
|
||||
assert!(cfg.container_workspace.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_args_defaults_to_none() {
|
||||
let cfg = load_from_str("").expect("parse empty");
|
||||
assert!(cfg.agent_args.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_agent_args_project_overrides_global() {
|
||||
let global_toml = r#"
|
||||
[agent_args]
|
||||
claude = ["--global-flag"]
|
||||
"#;
|
||||
let project_yaml = r#"
|
||||
agent_args:
|
||||
claude:
|
||||
- "--project-flag"
|
||||
"#;
|
||||
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()))
|
||||
.expect("resolve");
|
||||
let agent_args = cfg.agent_args.expect("agent_args should be Some");
|
||||
let claude_args = agent_args.get("claude").expect("claude args");
|
||||
assert_eq!(claude_args, &vec!["--project-flag"], "project should override global");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_agent_args() {
|
||||
let yaml = r#"
|
||||
agent_args:
|
||||
claude:
|
||||
- "--dangerously-skip-permissions"
|
||||
codex:
|
||||
- "--full-auto"
|
||||
"#;
|
||||
let cfg = load_from_str(yaml).expect("parse agent_args");
|
||||
let agent_args = cfg.agent_args.expect("agent_args should be Some");
|
||||
let claude_args = agent_args.get("claude").expect("claude args");
|
||||
assert_eq!(claude_args, &vec!["--dangerously-skip-permissions"]);
|
||||
let codex_args = agent_args.get("codex").expect("codex args");
|
||||
assert_eq!(codex_args, &vec!["--full-auto"]);
|
||||
}
|
||||
}
|
||||
|
||||
+132
-25
@@ -153,13 +153,6 @@ pub fn build_compose_env(workspace: &Path, config: &SandcageConfig) -> Result<Ha
|
||||
"SANDCAGE_HOME".into(),
|
||||
sandcage_home.to_string_lossy().into_owned(),
|
||||
);
|
||||
env.insert(
|
||||
"SANDCAGE_GLOBAL_JUSTFILE".into(),
|
||||
sandcage_home
|
||||
.join("Justfile")
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
);
|
||||
env.insert("SANDCAGE_CONTAINER_DIR".into(), container_dir);
|
||||
|
||||
Ok(env)
|
||||
@@ -231,6 +224,15 @@ pub fn build_run_args(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref mount_list) = config.mounts {
|
||||
for mount in mount_list {
|
||||
if mount.contains(':') {
|
||||
args.push("-v".to_string());
|
||||
args.push(mount.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if shell_override {
|
||||
args.push("--entrypoint".to_string());
|
||||
args.push("/bin/zsh".to_string());
|
||||
@@ -238,6 +240,16 @@ pub fn build_run_args(
|
||||
|
||||
args.push(service.to_string());
|
||||
|
||||
if !shell_override {
|
||||
if let Some(ref all_agent_args) = config.agent_args
|
||||
&& let Some(default_args) = all_agent_args.get(service)
|
||||
{
|
||||
for arg in default_args {
|
||||
args.push(arg.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for arg in extra_args {
|
||||
args.push(arg.clone());
|
||||
}
|
||||
@@ -555,10 +567,6 @@ mod tests {
|
||||
assert!(env.contains_key("SANDCAGE_GID"), "missing SANDCAGE_GID");
|
||||
assert!(env.contains_key("SANDCAGE_WORKSPACE"), "missing SANDCAGE_WORKSPACE");
|
||||
assert!(env.contains_key("SANDCAGE_HOME"), "missing SANDCAGE_HOME");
|
||||
assert!(
|
||||
env.contains_key("SANDCAGE_GLOBAL_JUSTFILE"),
|
||||
"missing SANDCAGE_GLOBAL_JUSTFILE"
|
||||
);
|
||||
assert!(env.contains_key("SANDCAGE_CONTAINER_DIR"), "missing SANDCAGE_CONTAINER_DIR");
|
||||
}
|
||||
|
||||
@@ -580,20 +588,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_compose_env_justfile_is_under_home() {
|
||||
let workspace = PathBuf::from("/tmp");
|
||||
let env = build_compose_env(&workspace, &SandcageConfig::default()).expect("build_compose_env");
|
||||
assert!(
|
||||
env["SANDCAGE_GLOBAL_JUSTFILE"].starts_with(&env["SANDCAGE_HOME"]),
|
||||
"SANDCAGE_GLOBAL_JUSTFILE should be under SANDCAGE_HOME"
|
||||
);
|
||||
assert!(
|
||||
env["SANDCAGE_GLOBAL_JUSTFILE"].ends_with("Justfile"),
|
||||
"SANDCAGE_GLOBAL_JUSTFILE should end with Justfile"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uid_gid_are_numeric() {
|
||||
let workspace = PathBuf::from("/tmp");
|
||||
@@ -821,6 +815,20 @@ mod tests {
|
||||
assert!(help_pos > service_pos);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_run_args_shell_override_skips_agent_args() {
|
||||
let mut agent_args = HashMap::new();
|
||||
agent_args.insert("claude".to_string(), vec!["--dangerously-skip-permissions".to_string()]);
|
||||
let config = SandcageConfig {
|
||||
agent_args: Some(agent_args),
|
||||
..Default::default()
|
||||
};
|
||||
let args = build_run_args("claude", "/tmp/compose.yml", &config, true, &[]);
|
||||
assert!(!args.contains(&"--dangerously-skip-permissions".to_string()),
|
||||
"agent_args must not be passed in shell mode");
|
||||
assert!(args.contains(&"--entrypoint".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_compose_env_container_dir_auto_derived() {
|
||||
let workspace = PathBuf::from("/home/user/projects/my-app");
|
||||
@@ -854,6 +862,105 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_run_args_with_mounts() {
|
||||
let config = SandcageConfig {
|
||||
mounts: Some(vec![
|
||||
"~/.ssh:/home/agent/.ssh:ro".to_string(),
|
||||
"~/.gitconfig:/home/agent/.gitconfig:ro".to_string(),
|
||||
]),
|
||||
..Default::default()
|
||||
};
|
||||
let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &[]);
|
||||
|
||||
assert!(args.contains(&"-v".to_string()), "should contain -v flag");
|
||||
assert!(args.contains(&"~/.ssh:/home/agent/.ssh:ro".to_string()));
|
||||
assert!(args.contains(&"~/.gitconfig:/home/agent/.gitconfig:ro".to_string()));
|
||||
|
||||
let service_pos = args.iter().position(|a| a == "claude").unwrap();
|
||||
let first_v = args.iter().position(|a| a == "-v").unwrap();
|
||||
assert!(first_v < service_pos, "-v flags must come before the service name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_run_args_skips_invalid_mounts() {
|
||||
let config = SandcageConfig {
|
||||
mounts: Some(vec![
|
||||
"/valid:/mount:ro".to_string(),
|
||||
"/no-colon-here".to_string(),
|
||||
]),
|
||||
..Default::default()
|
||||
};
|
||||
let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &[]);
|
||||
|
||||
let v_count = args.iter().filter(|a| a.as_str() == "-v").count();
|
||||
assert_eq!(v_count, 1, "only valid mounts should produce -v flags");
|
||||
assert!(args.contains(&"/valid:/mount:ro".to_string()));
|
||||
assert!(!args.contains(&"/no-colon-here".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_run_args_mounts_with_env_and_extra_args() {
|
||||
let mut env = HashMap::new();
|
||||
env.insert("FOO".to_string(), "bar".to_string());
|
||||
let config = SandcageConfig {
|
||||
env: Some(env),
|
||||
mounts: Some(vec!["~/.ssh:/home/agent/.ssh:ro".to_string()]),
|
||||
..Default::default()
|
||||
};
|
||||
let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &["--resume".to_string()]);
|
||||
|
||||
let service_pos = args.iter().position(|a| a == "claude").unwrap();
|
||||
let env_pos = args.iter().position(|a| a == "FOO=bar").unwrap();
|
||||
let mount_pos = args.iter().position(|a| a == "~/.ssh:/home/agent/.ssh:ro").unwrap();
|
||||
let resume_pos = args.iter().position(|a| a == "--resume").unwrap();
|
||||
|
||||
assert!(env_pos < service_pos, "env before service");
|
||||
assert!(mount_pos < service_pos, "mounts before service");
|
||||
assert!(resume_pos > service_pos, "extra args after service");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_run_args_prepends_default_agent_args() {
|
||||
let mut agent_args = HashMap::new();
|
||||
agent_args.insert("claude".to_string(), vec!["--dangerously-skip-permissions".to_string()]);
|
||||
let config = SandcageConfig {
|
||||
agent_args: Some(agent_args),
|
||||
..Default::default()
|
||||
};
|
||||
let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &["--resume".to_string()]);
|
||||
|
||||
let service_pos = args.iter().position(|a| a == "claude").unwrap();
|
||||
let default_pos = args.iter().position(|a| a == "--dangerously-skip-permissions").unwrap();
|
||||
let resume_pos = args.iter().position(|a| a == "--resume").unwrap();
|
||||
|
||||
assert!(default_pos > service_pos, "default args come after service name");
|
||||
assert!(default_pos < resume_pos, "default args come before CLI args");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_run_args_ignores_other_service_defaults() {
|
||||
let mut agent_args = HashMap::new();
|
||||
agent_args.insert("codex".to_string(), vec!["--full-auto".to_string()]);
|
||||
let config = SandcageConfig {
|
||||
agent_args: Some(agent_args),
|
||||
..Default::default()
|
||||
};
|
||||
let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &[]);
|
||||
|
||||
assert!(!args.contains(&"--full-auto".to_string()), "codex args should not appear for claude");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_run_args_no_defaults_cli_args_still_work() {
|
||||
let config = SandcageConfig::default();
|
||||
let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &["--resume".to_string()]);
|
||||
|
||||
let service_pos = args.iter().position(|a| a == "claude").unwrap();
|
||||
let resume_pos = args.iter().position(|a| a == "--resume").unwrap();
|
||||
assert!(resume_pos > service_pos);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_compose_env_container_dir_root_fallback() {
|
||||
let workspace = PathBuf::from("/");
|
||||
|
||||
+40
-21
@@ -34,16 +34,20 @@ pub fn preseed() -> Result<()> {
|
||||
|
||||
pub fn preseed_with_home(home: &Path) -> Result<()> {
|
||||
let sandcage_home = home.join(".sandcage");
|
||||
let agent_home = sandcage_home.join("home");
|
||||
|
||||
// 1. ~/.sandcage/.claude/
|
||||
let claude_dir = sandcage_home.join(".claude");
|
||||
// 1. ~/.sandcage/home/ — persistent agent home directory
|
||||
create_dir_all(&agent_home)?;
|
||||
|
||||
// 2. ~/.sandcage/home/.claude/
|
||||
let claude_dir = agent_home.join(".claude");
|
||||
create_dir_all(&claude_dir)?;
|
||||
|
||||
// 2. ~/.sandcage/.codex/
|
||||
let codex_dir = sandcage_home.join(".codex");
|
||||
// 3. ~/.sandcage/home/.codex/
|
||||
let codex_dir = agent_home.join(".codex");
|
||||
create_dir_all(&codex_dir)?;
|
||||
|
||||
// 3. Seed Claude settings.json from the bundled template
|
||||
// 4. Seed Claude settings.json from the bundled template
|
||||
let settings_dest = claude_dir.join("settings.json");
|
||||
if !settings_dest.exists() {
|
||||
std::fs::write(&settings_dest, CLAUDE_SETTINGS_TEMPLATE)
|
||||
@@ -51,15 +55,15 @@ pub fn preseed_with_home(home: &Path) -> Result<()> {
|
||||
println!("sandcage: seeded {} from template", settings_dest.display());
|
||||
}
|
||||
|
||||
// 4. Seed .claude.json so the bind mount targets a file, not a directory
|
||||
let claude_json = sandcage_home.join(".claude.json");
|
||||
// 5. Seed .claude.json so the bind mount targets a file, not a directory
|
||||
let claude_json = agent_home.join(".claude.json");
|
||||
if !claude_json.exists() {
|
||||
std::fs::write(&claude_json, "{}\n")
|
||||
.map_err(|e| InitError::WriteFileFailed(claude_json.clone(), e))?;
|
||||
}
|
||||
|
||||
// 5. Create an empty Justfile if absent
|
||||
let justfile = sandcage_home.join("Justfile");
|
||||
// 6. Create an empty .justfile if absent
|
||||
let justfile = agent_home.join(".justfile");
|
||||
if !justfile.exists() {
|
||||
std::fs::write(&justfile, "")
|
||||
.map_err(|e| InitError::WriteFileFailed(justfile.clone(), e))?;
|
||||
@@ -146,7 +150,12 @@ fn build_yaml(ecosystem: Ecosystem) -> String {
|
||||
{toolchain_and_packages}\
|
||||
\n\
|
||||
# mounts:\n\
|
||||
# - /path/on/host:/path/in/container\n\
|
||||
# - ~/.ssh:/home/agent/.ssh:ro\n\
|
||||
# - ~/.gitconfig:/home/agent/.gitconfig:ro\n\
|
||||
\n\
|
||||
# agent_args:\n\
|
||||
# claude:\n\
|
||||
# - \"--dangerously-skip-permissions\"\n\
|
||||
\n\
|
||||
# shell: zsh\n\
|
||||
\n\
|
||||
@@ -190,17 +199,27 @@ mod tests {
|
||||
preseed_with_home(home)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_home_dir() {
|
||||
let home = TempDir::new().expect("create tempdir");
|
||||
run(home.path()).expect("preseed");
|
||||
assert!(
|
||||
home.path().join(".sandcage/home").is_dir(),
|
||||
"home dir should exist"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_claude_and_codex_dirs() {
|
||||
let home = TempDir::new().expect("create tempdir");
|
||||
run(home.path()).expect("preseed");
|
||||
|
||||
assert!(
|
||||
home.path().join(".sandcage/.claude").is_dir(),
|
||||
home.path().join(".sandcage/home/.claude").is_dir(),
|
||||
".claude dir should exist"
|
||||
);
|
||||
assert!(
|
||||
home.path().join(".sandcage/.codex").is_dir(),
|
||||
home.path().join(".sandcage/home/.codex").is_dir(),
|
||||
".codex dir should exist"
|
||||
);
|
||||
}
|
||||
@@ -210,7 +229,7 @@ mod tests {
|
||||
let home = TempDir::new().expect("create tempdir");
|
||||
run(home.path()).expect("preseed");
|
||||
|
||||
let settings = home.path().join(".sandcage/.claude/settings.json");
|
||||
let settings = home.path().join(".sandcage/home/.claude/settings.json");
|
||||
assert!(settings.exists(), "settings.json should be created");
|
||||
|
||||
let content = std::fs::read_to_string(&settings).expect("read settings");
|
||||
@@ -225,7 +244,7 @@ mod tests {
|
||||
fn does_not_overwrite_existing_claude_settings() {
|
||||
let home = TempDir::new().expect("create tempdir");
|
||||
// Create the directory and put custom content in settings.json first
|
||||
let claude_dir = home.path().join(".sandcage/.claude");
|
||||
let claude_dir = home.path().join(".sandcage/home/.claude");
|
||||
std::fs::create_dir_all(&claude_dir).expect("mkdir");
|
||||
let settings = claude_dir.join("settings.json");
|
||||
std::fs::write(&settings, r#"{"existing": true}"#).expect("write");
|
||||
@@ -244,7 +263,7 @@ mod tests {
|
||||
let home = TempDir::new().expect("create tempdir");
|
||||
run(home.path()).expect("preseed");
|
||||
|
||||
let justfile = home.path().join(".sandcage/Justfile");
|
||||
let justfile = home.path().join(".sandcage/home/.justfile");
|
||||
assert!(justfile.exists(), "Justfile should be created");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&justfile).expect("read"),
|
||||
@@ -257,9 +276,9 @@ mod tests {
|
||||
fn does_not_overwrite_existing_justfile() {
|
||||
let home = TempDir::new().expect("create tempdir");
|
||||
// Seed a Justfile with content first
|
||||
let sandcage_dir = home.path().join(".sandcage");
|
||||
let sandcage_dir = home.path().join(".sandcage/home");
|
||||
std::fs::create_dir_all(&sandcage_dir).expect("mkdir");
|
||||
let justfile = sandcage_dir.join("Justfile");
|
||||
let justfile = sandcage_dir.join(".justfile");
|
||||
std::fs::write(&justfile, "default:\n\t@echo hello").expect("write");
|
||||
|
||||
run(home.path()).expect("preseed");
|
||||
@@ -278,10 +297,10 @@ mod tests {
|
||||
run(home.path()).expect("second preseed should also succeed");
|
||||
|
||||
// All artefacts should still exist after two runs
|
||||
assert!(home.path().join(".sandcage/.claude").is_dir());
|
||||
assert!(home.path().join(".sandcage/.codex").is_dir());
|
||||
assert!(home.path().join(".sandcage/.claude/settings.json").exists());
|
||||
assert!(home.path().join(".sandcage/Justfile").exists());
|
||||
assert!(home.path().join(".sandcage/home/.claude").is_dir());
|
||||
assert!(home.path().join(".sandcage/home/.codex").is_dir());
|
||||
assert!(home.path().join(".sandcage/home/.claude/settings.json").exists());
|
||||
assert!(home.path().join(".sandcage/home/.justfile").exists());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod config;
|
||||
pub mod docker;
|
||||
pub mod init;
|
||||
pub mod setup;
|
||||
pub mod workspace;
|
||||
|
||||
@@ -6,6 +6,7 @@ use thiserror::Error;
|
||||
use sandcage::config;
|
||||
use sandcage::docker;
|
||||
use sandcage::init;
|
||||
use sandcage::setup;
|
||||
use sandcage::workspace;
|
||||
|
||||
/// Sandboxed containers for AI coding agents
|
||||
@@ -60,6 +61,25 @@ enum Commands {
|
||||
},
|
||||
/// Initialize a .sandcage.yml for a project
|
||||
Init,
|
||||
/// Guided configuration helpers
|
||||
Setup {
|
||||
#[command(subcommand)]
|
||||
action: SetupAction,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum SetupAction {
|
||||
/// Configure SSH key access for containers
|
||||
Ssh {
|
||||
/// Write to global config instead of project config
|
||||
#[arg(long)]
|
||||
global: bool,
|
||||
|
||||
/// Skip confirmation prompt
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Diagnostic)]
|
||||
@@ -79,6 +99,10 @@ enum AppError {
|
||||
#[error(transparent)]
|
||||
#[diagnostic(transparent)]
|
||||
Docker(#[from] docker::DockerError),
|
||||
|
||||
#[error(transparent)]
|
||||
#[diagnostic(transparent)]
|
||||
Setup(#[from] setup::SetupError),
|
||||
}
|
||||
|
||||
fn run_agent(service: &str, path: Option<PathBuf>, shell_override: bool, agent_args: Vec<String>) -> std::result::Result<(), AppError> {
|
||||
@@ -119,6 +143,11 @@ fn main() -> miette::Result<()> {
|
||||
let workspace = workspace::resolve_workspace(None)?;
|
||||
init::scaffold_project(&workspace)?;
|
||||
}
|
||||
Commands::Setup { action } => match action {
|
||||
SetupAction::Ssh { global, yes } => {
|
||||
setup::run_ssh_setup(global, yes)?;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use miette::Diagnostic;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::config::SandcageConfig;
|
||||
|
||||
#[derive(Debug, Error, Diagnostic)]
|
||||
pub enum SetupError {
|
||||
#[error("No SSH directory found at {0}")]
|
||||
#[diagnostic(code(sandcage::setup::no_ssh_dir))]
|
||||
NoSshDir(PathBuf),
|
||||
|
||||
#[error("Failed to read directory {0}: {1}")]
|
||||
#[diagnostic(code(sandcage::setup::read_dir_failed))]
|
||||
ReadDirFailed(PathBuf, #[source] std::io::Error),
|
||||
|
||||
#[error("No .sandcage.yml found. Run `sandcage init` first, or use `--global`.")]
|
||||
#[diagnostic(code(sandcage::setup::no_project_config))]
|
||||
NoProjectConfig,
|
||||
|
||||
#[error("Failed to read config file {0}: {1}")]
|
||||
#[diagnostic(code(sandcage::setup::config_read_failed))]
|
||||
ConfigReadFailed(PathBuf, #[source] std::io::Error),
|
||||
|
||||
#[error("Failed to parse config file {0}: {1}")]
|
||||
#[diagnostic(code(sandcage::setup::config_parse_failed))]
|
||||
ConfigParseFailed(PathBuf, String),
|
||||
|
||||
#[error("Failed to write config file {0}: {1}")]
|
||||
#[diagnostic(code(sandcage::setup::config_write_failed))]
|
||||
ConfigWriteFailed(PathBuf, #[source] std::io::Error),
|
||||
|
||||
#[error("Cannot determine home directory")]
|
||||
#[diagnostic(code(sandcage::setup::no_home_dir))]
|
||||
NoHomeDir,
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, SetupError>;
|
||||
|
||||
const SSH_MOUNT: &str = "~/.ssh:/home/agent/.ssh:ro";
|
||||
|
||||
const SSH_CONFIG_FILES: &[&str] = &["config", "known_hosts", "known_hosts.old", "authorized_keys"];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SshScanResult {
|
||||
pub keys: Vec<String>,
|
||||
pub config_files: Vec<String>,
|
||||
pub other_count: usize,
|
||||
}
|
||||
|
||||
pub fn scan_ssh_dir(ssh_dir: &Path) -> Result<SshScanResult> {
|
||||
if !ssh_dir.is_dir() {
|
||||
return Err(SetupError::NoSshDir(ssh_dir.to_path_buf()));
|
||||
}
|
||||
|
||||
let entries = std::fs::read_dir(ssh_dir)
|
||||
.map_err(|e| SetupError::ReadDirFailed(ssh_dir.to_path_buf(), e))?;
|
||||
|
||||
let mut keys = Vec::new();
|
||||
let mut config_files = Vec::new();
|
||||
let mut other_count: usize = 0;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| SetupError::ReadDirFailed(ssh_dir.to_path_buf(), e))?;
|
||||
let file_name = entry.file_name().to_string_lossy().into_owned();
|
||||
|
||||
if file_name.ends_with(".pub") {
|
||||
let base = file_name.strip_suffix(".pub").unwrap().to_string();
|
||||
keys.push(base);
|
||||
} else if SSH_CONFIG_FILES.contains(&file_name.as_str()) {
|
||||
config_files.push(file_name);
|
||||
} else {
|
||||
other_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
keys.sort();
|
||||
config_files.sort();
|
||||
|
||||
Ok(SshScanResult {
|
||||
keys,
|
||||
config_files,
|
||||
other_count,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn check_existing_mount(config: &SandcageConfig, mount: &str) -> bool {
|
||||
config
|
||||
.mounts
|
||||
.as_ref()
|
||||
.is_some_and(|m| m.iter().any(|entry| entry == mount))
|
||||
}
|
||||
|
||||
pub fn add_mount_to_yaml(path: &Path, mount: &str) -> Result<()> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| SetupError::ConfigReadFailed(path.to_path_buf(), e))?;
|
||||
|
||||
let mut config: SandcageConfig = if content.trim().is_empty() {
|
||||
SandcageConfig::default()
|
||||
} else {
|
||||
serde_yaml::from_str(&content)
|
||||
.map_err(|e| SetupError::ConfigParseFailed(path.to_path_buf(), e.to_string()))?
|
||||
};
|
||||
|
||||
let mounts = config.mounts.get_or_insert_with(Vec::new);
|
||||
mounts.push(mount.to_string());
|
||||
|
||||
let yaml = serde_yaml::to_string(&config)
|
||||
.map_err(|e| SetupError::ConfigParseFailed(path.to_path_buf(), e.to_string()))?;
|
||||
|
||||
let output = rehydrate_yaml_comments(&content, &yaml);
|
||||
|
||||
std::fs::write(path, output)
|
||||
.map_err(|e| SetupError::ConfigWriteFailed(path.to_path_buf(), e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rehydrate_yaml_comments(original: &str, generated: &str) -> String {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let mut key_comments: Vec<(Option<String>, Vec<String>)> = Vec::new();
|
||||
let mut pending: Vec<String> = Vec::new();
|
||||
|
||||
for line in original.lines() {
|
||||
let trimmed = line.trim();
|
||||
let is_blank = trimmed.is_empty();
|
||||
let is_top_comment =
|
||||
trimmed.starts_with('#') && !line.starts_with(' ') && !line.starts_with('\t');
|
||||
let is_top_level_key = !is_blank
|
||||
&& !is_top_comment
|
||||
&& !line.starts_with(' ')
|
||||
&& !line.starts_with('\t')
|
||||
&& !line.starts_with('-')
|
||||
&& line.contains(':');
|
||||
|
||||
if is_top_level_key {
|
||||
let key = line.split(':').next().unwrap().trim().to_string();
|
||||
key_comments.push((Some(key), std::mem::take(&mut pending)));
|
||||
} else if is_blank || is_top_comment {
|
||||
pending.push(line.to_string());
|
||||
}
|
||||
}
|
||||
if !pending.is_empty() {
|
||||
key_comments.push((None, pending));
|
||||
}
|
||||
|
||||
let comment_map: HashMap<&str, &[String]> = key_comments
|
||||
.iter()
|
||||
.filter_map(|(k, v)| k.as_deref().map(|k| (k, v.as_slice())))
|
||||
.collect();
|
||||
|
||||
let preamble = key_comments
|
||||
.first()
|
||||
.and_then(|(k, v)| if k.is_some() { Some(v.as_slice()) } else { None });
|
||||
|
||||
let trailer = key_comments
|
||||
.last()
|
||||
.and_then(|(k, v)| if k.is_none() { Some(v.as_slice()) } else { None });
|
||||
|
||||
let mut result = String::new();
|
||||
let mut first_key = true;
|
||||
|
||||
for line in generated.lines() {
|
||||
let trimmed = line.trim();
|
||||
let is_top_level_key = !trimmed.is_empty()
|
||||
&& !line.starts_with(' ')
|
||||
&& !line.starts_with('\t')
|
||||
&& !line.starts_with('-')
|
||||
&& line.contains(':');
|
||||
|
||||
if is_top_level_key {
|
||||
let key = line.split(':').next().unwrap().trim();
|
||||
if first_key {
|
||||
if let Some(pre) = preamble {
|
||||
for c in pre {
|
||||
result.push_str(c);
|
||||
result.push('\n');
|
||||
}
|
||||
}
|
||||
first_key = false;
|
||||
} else if let Some(comments) = comment_map.get(key) {
|
||||
for c in *comments {
|
||||
result.push_str(c);
|
||||
result.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
result.push_str(line);
|
||||
result.push('\n');
|
||||
}
|
||||
|
||||
if let Some(trail) = trailer {
|
||||
for c in trail {
|
||||
result.push_str(c);
|
||||
result.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub fn add_mount_to_toml(path: &Path, mount: &str) -> Result<()> {
|
||||
let content = if path.exists() {
|
||||
std::fs::read_to_string(path)
|
||||
.map_err(|e| SetupError::ConfigReadFailed(path.to_path_buf(), e))?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let mut doc: toml_edit::DocumentMut = content
|
||||
.parse()
|
||||
.map_err(|e: toml_edit::TomlError| {
|
||||
SetupError::ConfigParseFailed(path.to_path_buf(), e.to_string())
|
||||
})?;
|
||||
|
||||
let mounts = doc.entry("mounts").or_insert_with(|| {
|
||||
toml_edit::Item::Value(toml_edit::Value::Array(toml_edit::Array::new()))
|
||||
});
|
||||
|
||||
if let Some(arr) = mounts.as_array_mut() {
|
||||
arr.push(mount);
|
||||
}
|
||||
|
||||
std::fs::write(path, doc.to_string())
|
||||
.map_err(|e| SetupError::ConfigWriteFailed(path.to_path_buf(), e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn display_scan_result(result: &SshScanResult) {
|
||||
if !result.keys.is_empty() {
|
||||
eprintln!(" Keys: {}", result.keys.join(", "));
|
||||
}
|
||||
if !result.config_files.is_empty() {
|
||||
eprintln!(" Config: {}", result.config_files.join(", "));
|
||||
}
|
||||
if result.other_count > 0 {
|
||||
eprintln!(
|
||||
" Other: {} other file{}",
|
||||
result.other_count,
|
||||
if result.other_count == 1 { "" } else { "s" }
|
||||
);
|
||||
}
|
||||
if result.keys.is_empty() && result.config_files.is_empty() && result.other_count == 0 {
|
||||
eprintln!(" (empty)");
|
||||
}
|
||||
eprintln!();
|
||||
eprintln!("This will mount ~/.ssh into containers as read-only.");
|
||||
}
|
||||
|
||||
pub fn run_ssh_setup(global: bool, yes: bool) -> Result<()> {
|
||||
let home = dirs::home_dir().ok_or(SetupError::NoHomeDir)?;
|
||||
let ssh_dir = home.join(".ssh");
|
||||
|
||||
let result = scan_ssh_dir(&ssh_dir)?;
|
||||
|
||||
eprintln!("sandcage: found SSH directory at ~/.ssh");
|
||||
eprintln!();
|
||||
display_scan_result(&result);
|
||||
|
||||
let (config_path, config_label) = if global {
|
||||
(
|
||||
home.join(".sandcage").join("config.toml"),
|
||||
"~/.sandcage/config.toml".to_string(),
|
||||
)
|
||||
} 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, ".sandcage.yml".to_string())
|
||||
};
|
||||
|
||||
// Check if already configured
|
||||
if config_path.exists() {
|
||||
let existing = if global {
|
||||
let content = std::fs::read_to_string(&config_path)
|
||||
.map_err(|e| SetupError::ConfigReadFailed(config_path.clone(), e))?;
|
||||
let doc: toml_edit::DocumentMut = content
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(|e: toml_edit::TomlError| {
|
||||
SetupError::ConfigParseFailed(config_path.clone(), e.to_string())
|
||||
})?;
|
||||
doc.get("mounts")
|
||||
.and_then(|m: &toml_edit::Item| m.as_array())
|
||||
.is_some_and(|arr: &toml_edit::Array| {
|
||||
arr.iter()
|
||||
.any(|v: &toml_edit::Value| v.as_str() == Some(SSH_MOUNT))
|
||||
})
|
||||
} else {
|
||||
let cfg = crate::config::load(&config_path)
|
||||
.map_err(|e| SetupError::ConfigParseFailed(config_path.clone(), e.to_string()))?;
|
||||
check_existing_mount(&cfg, SSH_MOUNT)
|
||||
};
|
||||
|
||||
if existing {
|
||||
eprintln!("sandcage: SSH mount already configured in {config_label}");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm
|
||||
if !yes {
|
||||
let confirmed = dialoguer::Confirm::new()
|
||||
.with_prompt(format!("Add SSH mount to {config_label}?"))
|
||||
.default(false)
|
||||
.interact()
|
||||
.unwrap_or(false);
|
||||
|
||||
if !confirmed {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Write
|
||||
if global {
|
||||
if let Some(parent) = config_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| SetupError::ConfigWriteFailed(config_path.clone(), e))?;
|
||||
}
|
||||
add_mount_to_toml(&config_path, SSH_MOUNT)?;
|
||||
} else {
|
||||
add_mount_to_yaml(&config_path, SSH_MOUNT)?;
|
||||
}
|
||||
|
||||
eprintln!("sandcage: added SSH mount to {config_label}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use tempfile::{NamedTempFile, TempDir};
|
||||
|
||||
fn touch(dir: &Path, name: &str) {
|
||||
std::fs::write(dir.join(name), "").expect("touch file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_finds_keys_by_pub_extension() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
touch(dir.path(), "id_ed25519");
|
||||
touch(dir.path(), "id_ed25519.pub");
|
||||
touch(dir.path(), "work_github");
|
||||
touch(dir.path(), "work_github.pub");
|
||||
|
||||
let result = scan_ssh_dir(dir.path()).unwrap();
|
||||
assert_eq!(result.keys, vec!["id_ed25519", "work_github"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_finds_config_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
touch(dir.path(), "config");
|
||||
touch(dir.path(), "known_hosts");
|
||||
touch(dir.path(), "authorized_keys");
|
||||
|
||||
let result = scan_ssh_dir(dir.path()).unwrap();
|
||||
assert_eq!(
|
||||
result.config_files,
|
||||
vec!["authorized_keys", "config", "known_hosts"]
|
||||
);
|
||||
assert_eq!(result.keys, Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_counts_other_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
touch(dir.path(), "id_rsa");
|
||||
touch(dir.path(), "id_rsa.pub");
|
||||
touch(dir.path(), "config");
|
||||
touch(dir.path(), "environment");
|
||||
touch(dir.path(), "random_file");
|
||||
|
||||
let result = scan_ssh_dir(dir.path()).unwrap();
|
||||
assert_eq!(result.keys, vec!["id_rsa"]);
|
||||
assert_eq!(result.config_files, vec!["config"]);
|
||||
assert_eq!(result.other_count, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_empty_dir() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let result = scan_ssh_dir(dir.path()).unwrap();
|
||||
assert_eq!(result.keys, Vec::<String>::new());
|
||||
assert_eq!(result.config_files, Vec::<String>::new());
|
||||
assert_eq!(result.other_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_nonexistent_dir_errors() {
|
||||
let result = scan_ssh_dir(Path::new("/nonexistent/ssh/dir"));
|
||||
assert!(matches!(result, Err(SetupError::NoSshDir(_))));
|
||||
}
|
||||
|
||||
// -- check_existing_mount tests --
|
||||
|
||||
#[test]
|
||||
fn check_existing_mount_found() {
|
||||
let config = SandcageConfig {
|
||||
mounts: Some(vec!["~/.ssh:/home/agent/.ssh:ro".to_string()]),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(check_existing_mount(&config, "~/.ssh:/home/agent/.ssh:ro"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_existing_mount_not_found() {
|
||||
let config = SandcageConfig {
|
||||
mounts: Some(vec!["/data:/mnt:ro".to_string()]),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!check_existing_mount(&config, "~/.ssh:/home/agent/.ssh:ro"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_existing_mount_no_mounts() {
|
||||
let config = SandcageConfig::default();
|
||||
assert!(!check_existing_mount(&config, "~/.ssh:/home/agent/.ssh:ro"));
|
||||
}
|
||||
|
||||
// -- add_mount_to_yaml tests --
|
||||
|
||||
#[test]
|
||||
fn add_mount_to_yaml_with_existing_mounts() {
|
||||
let yaml = "env:\n FOO: bar\nmounts:\n - /data:/mnt\n";
|
||||
let mut tmp = NamedTempFile::new().unwrap();
|
||||
write!(tmp, "{yaml}").unwrap();
|
||||
|
||||
add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap();
|
||||
|
||||
let content = std::fs::read_to_string(tmp.path()).unwrap();
|
||||
assert!(content.contains("~/.ssh:/home/agent/.ssh:ro"));
|
||||
assert!(content.contains("/data:/mnt"));
|
||||
assert!(content.contains("FOO"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_mount_to_yaml_no_existing_mounts() {
|
||||
let yaml = "env:\n FOO: bar\n";
|
||||
let mut tmp = NamedTempFile::new().unwrap();
|
||||
write!(tmp, "{yaml}").unwrap();
|
||||
|
||||
add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap();
|
||||
|
||||
let content = std::fs::read_to_string(tmp.path()).unwrap();
|
||||
assert!(content.contains("~/.ssh:/home/agent/.ssh:ro"));
|
||||
assert!(content.contains("FOO"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_mount_to_yaml_empty_file() {
|
||||
let mut tmp = NamedTempFile::new().unwrap();
|
||||
write!(tmp, "").unwrap();
|
||||
|
||||
add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap();
|
||||
|
||||
let content = std::fs::read_to_string(tmp.path()).unwrap();
|
||||
assert!(content.contains("~/.ssh:/home/agent/.ssh:ro"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_mount_to_yaml_preserves_comments() {
|
||||
let yaml = "# Environment\nenv:\n FOO: bar\n\n# Mount points\nmounts:\n- /data:/mnt\n";
|
||||
let mut tmp = NamedTempFile::new().unwrap();
|
||||
write!(tmp, "{yaml}").unwrap();
|
||||
|
||||
add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap();
|
||||
|
||||
let content = std::fs::read_to_string(tmp.path()).unwrap();
|
||||
assert!(content.contains("# Environment"), "preamble comment preserved");
|
||||
assert!(content.contains("# Mount points"), "inline comment preserved");
|
||||
assert!(content.contains("~/.ssh:/home/agent/.ssh:ro"));
|
||||
assert!(content.contains("/data:/mnt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_mount_to_yaml_preserves_trailing_comments() {
|
||||
let yaml = "mounts:\n- /data:/mnt\n\n# end of file\n";
|
||||
let mut tmp = NamedTempFile::new().unwrap();
|
||||
write!(tmp, "{yaml}").unwrap();
|
||||
|
||||
add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap();
|
||||
|
||||
let content = std::fs::read_to_string(tmp.path()).unwrap();
|
||||
assert!(content.contains("# end of file"), "trailing comment preserved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rehydrate_preserves_blank_lines_between_keys() {
|
||||
let original = "env:\n A: 1\n\nmounts:\n- /data:/mnt\n";
|
||||
let generated = "env:\n A: '1'\nmounts:\n- /data:/mnt\n- /new:/new\n";
|
||||
let result = rehydrate_yaml_comments(original, generated);
|
||||
assert!(result.contains("\n\nmounts:"), "blank line before mounts preserved");
|
||||
}
|
||||
|
||||
// -- add_mount_to_toml tests --
|
||||
|
||||
#[test]
|
||||
fn add_mount_to_toml_new_file() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
|
||||
add_mount_to_toml(&path, "~/.ssh:/home/agent/.ssh:ro").unwrap();
|
||||
|
||||
let content = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(content.contains("~/.ssh:/home/agent/.ssh:ro"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_mount_to_toml_existing_file() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(&path, "shell = \"zsh\"\n").unwrap();
|
||||
|
||||
add_mount_to_toml(&path, "~/.ssh:/home/agent/.ssh:ro").unwrap();
|
||||
|
||||
let content = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(content.contains("~/.ssh:/home/agent/.ssh:ro"));
|
||||
assert!(content.contains("zsh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_mount_to_toml_existing_mounts() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(&path, "mounts = [\"/data:/mnt\"]\n").unwrap();
|
||||
|
||||
add_mount_to_toml(&path, "~/.ssh:/home/agent/.ssh:ro").unwrap();
|
||||
|
||||
let content = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(content.contains("~/.ssh:/home/agent/.ssh:ro"));
|
||||
assert!(content.contains("/data:/mnt"));
|
||||
}
|
||||
}
|
||||
@@ -45,3 +45,7 @@ export *FLAGS:
|
||||
# Dry-run export (inspect without pushing).
|
||||
export-dry:
|
||||
uv run python scripts/export.py push --dry-run
|
||||
|
||||
install:
|
||||
cargo install --path crates/sandcage
|
||||
sandcage build --force
|
||||
|
||||
+3
-1
@@ -3,7 +3,9 @@ name = "sandcage"
|
||||
version = "0.1.0"
|
||||
description = "Tooling and scripts for the Sandcage project"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = []
|
||||
dependencies = [
|
||||
"pillow>=12.2.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
|
||||
+3
-1
@@ -148,7 +148,9 @@ def run_export(*, repo_root: Path, cfg: ExportConfig,
|
||||
diff = _run(["git", "diff", "--cached", "--quiet"],
|
||||
cwd=worktree, check=False)
|
||||
if diff.returncode != 0:
|
||||
_run(["git", "-c", "user.email=export@sandcage", "-c", "user.name=sandcage-export",
|
||||
git_name = _run(["git", "config", "user.name"], cwd=repo_root).stdout.strip()
|
||||
git_email = _run(["git", "config", "user.email"], cwd=repo_root).stdout.strip()
|
||||
_run(["git", "-c", f"user.email={git_email}", "-c", f"user.name={git_name}",
|
||||
"commit", "-m",
|
||||
f"\U0001f947 export from upstream ({source_sha})"],
|
||||
cwd=worktree)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Generate icon variants from the sandcage source image."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SRC = ROOT / ".workpad" / "assets" / "sandcage.png"
|
||||
OUT = ROOT / "local"
|
||||
|
||||
|
||||
def square_crop(src: Path, size: int = 1024) -> Image.Image:
|
||||
img = Image.open(src).convert("RGBA")
|
||||
w, h = img.size
|
||||
side = min(w, h)
|
||||
left = (w - side) // 2
|
||||
top = (h - side) // 2
|
||||
img = img.crop((left, top, left + side, top + side))
|
||||
return img.resize((size, size), Image.LANCZOS)
|
||||
|
||||
|
||||
def invert_rgb(img: Image.Image) -> Image.Image:
|
||||
from PIL import ImageChops
|
||||
r, g, b, a = img.split()
|
||||
return Image.merge("RGBA", (ImageChops.invert(r), ImageChops.invert(g), ImageChops.invert(b), a))
|
||||
|
||||
|
||||
def lighten_dune_interior(img: Image.Image, grey: int = 80) -> Image.Image:
|
||||
img = img.copy()
|
||||
draw = ImageDraw.Draw(img)
|
||||
fill = (grey, grey, grey, 255)
|
||||
# tips found at (56,567) and (973,567), dune bottom at y=652
|
||||
# draw a filled arc (pieslice) from tip to tip curving below to seal the interior
|
||||
# pieslice bbox: center the ellipse so it passes through both tips
|
||||
cx = (56 + 973) // 2
|
||||
half_w = (973 - 56) // 2
|
||||
arc_depth = 120
|
||||
bbox = (cx - half_w, 567 - arc_depth, cx + half_w, 567 + arc_depth)
|
||||
# draw just the bottom arc in grey to create the barrier
|
||||
draw.arc(bbox, 0, 180, fill=fill, width=4)
|
||||
# flood fill the enclosed black region
|
||||
ImageDraw.floodfill(img, (300, 620), fill, thresh=60)
|
||||
return img
|
||||
|
||||
|
||||
def apply_vertical_bars(img: Image.Image, bar_width: int = 48, gap_width: int = 48, opacity: float = 0.5) -> Image.Image:
|
||||
img = img.copy()
|
||||
r, g, b, a = img.split()
|
||||
bar_mask = Image.new("L", img.size, 255)
|
||||
draw = ImageDraw.Draw(bar_mask)
|
||||
bar_alpha = int(255 * opacity)
|
||||
x = 0
|
||||
while x < img.size[0]:
|
||||
draw.rectangle((x, 0, x + bar_width - 1, img.size[1]), fill=bar_alpha)
|
||||
x += bar_width + gap_width
|
||||
from PIL import ImageChops
|
||||
a = ImageChops.multiply(a, bar_mask)
|
||||
return Image.merge("RGBA", (r, g, b, a))
|
||||
|
||||
|
||||
def apply_circle_mask(img: Image.Image, size: int = 1024) -> Image.Image:
|
||||
# circle passes through dune tips (56,567) and (973,567)
|
||||
# center at midpoint, radius = half the tip distance
|
||||
cx = (56 + 973) // 2 # 514
|
||||
cy = 567
|
||||
r = (973 - 56) // 2 # 458
|
||||
# crop to bounding box of this circle, then resize to output size
|
||||
left = max(cx - r, 0)
|
||||
top = max(cy - r, 0)
|
||||
right = min(cx + r, img.size[0])
|
||||
bottom = min(cy + r, img.size[1])
|
||||
cropped = img.crop((left, top, right, bottom))
|
||||
cropped = cropped.resize((size, size), Image.LANCZOS)
|
||||
# apply circular mask
|
||||
mask = Image.new("L", (size, size), 0)
|
||||
draw = ImageDraw.Draw(mask)
|
||||
draw.ellipse((0, 0, size, size), fill=255)
|
||||
result = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
result.paste(cropped, (0, 0), mask)
|
||||
return result
|
||||
|
||||
|
||||
def save(img: Image.Image, path: Path) -> Path:
|
||||
img.save(path)
|
||||
print(f"Wrote {path}")
|
||||
return path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
base = square_crop(SRC)
|
||||
inverted = invert_rgb(base)
|
||||
|
||||
save(apply_circle_mask(base), OUT / "sandcage-round.png")
|
||||
save(apply_circle_mask(inverted), OUT / "sandcage-round-inverted.png")
|
||||
|
||||
inverted_grey = lighten_dune_interior(inverted)
|
||||
save(apply_circle_mask(inverted_grey), OUT / "sandcage-round-inverted-grey.png")
|
||||
|
||||
save(apply_circle_mask(apply_vertical_bars(base)), OUT / "sandcage-round-bars.png")
|
||||
save(apply_circle_mask(apply_vertical_bars(inverted)), OUT / "sandcage-round-inverted-bars.png")
|
||||
save(apply_circle_mask(apply_vertical_bars(inverted_grey)), OUT / "sandcage-round-inverted-grey-bars.png")
|
||||
@@ -1,50 +1,118 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490 },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371 },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783 },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112 },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129 },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528 },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401 },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094 },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402 },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194 },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423 },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580 },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624 },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114 },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542 },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159 },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386 },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021 },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225 },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541 },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720 },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498 },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -58,15 +126,18 @@ dependencies = [
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sandcage"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "pillow" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
@@ -74,6 +145,7 @@ dev = [
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "pillow", specifier = ">=12.2.0" }]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "pytest", specifier = ">=9.0.3" }]
|
||||
|
||||
Reference in New Issue
Block a user