feat(spaceshd): configurable default shell
Add a config.rs module that loads optional ~/.spacesh/config.toml and a default_shell() resolver: SPACESH_SHELL env -> config.toml default_shell -> login shell from the passwd DB -> $SHELL -> /bin/sh. The passwd lookup matters under launchd where $SHELL is absent. All plain-panel spawn sites in server.rs now use the resolver. Adds toml + libc deps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+83
@@ -246,6 +246,12 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
@@ -382,6 +388,12 @@ dependencies = [
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
@@ -403,6 +415,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ioctl-rs"
|
||||
version = "0.1.6"
|
||||
@@ -736,6 +758,15 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial"
|
||||
version = "0.4.0"
|
||||
@@ -889,6 +920,7 @@ dependencies = [
|
||||
"dirs",
|
||||
"fs2",
|
||||
"futures",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"spacesh-core",
|
||||
@@ -897,6 +929,7 @@ dependencies = [
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -986,6 +1019,47 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_write",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
@@ -1200,6 +1274,15 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.10.1"
|
||||
|
||||
@@ -26,5 +26,7 @@ portable-pty = "0.8"
|
||||
alacritty_terminal = "0.25"
|
||||
fs2 = "0.4"
|
||||
dirs = "5"
|
||||
toml = "0.8"
|
||||
libc = "0.2"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
clap_complete = "4"
|
||||
|
||||
@@ -22,3 +22,5 @@ thiserror.workspace = true
|
||||
futures.workspace = true
|
||||
fs2.workspace = true
|
||||
dirs.workspace = true
|
||||
toml.workspace = true
|
||||
libc.workspace = true
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Daemon configuration loaded from `~/.spacesh/config.toml`.
|
||||
//!
|
||||
//! The file is optional; every field has a sane fallback so a missing or
|
||||
//! partial config never breaks startup.
|
||||
|
||||
use std::path::Path;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct Config {
|
||||
/// Shell launched for plain (no-command) panels. When unset, the daemon
|
||||
/// auto-detects the user's login shell.
|
||||
#[serde(default)]
|
||||
pub default_shell: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Load `~/.spacesh/config.toml`. Any error (missing file, bad TOML) yields defaults.
|
||||
pub fn load() -> Self {
|
||||
let Ok(dir) = crate::lifecycle::spacesh_dir() else { return Self::default() };
|
||||
Self::from_path(&dir.join("config.toml"))
|
||||
}
|
||||
|
||||
pub fn from_path(path: &Path) -> Self {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(s) => toml::from_str(&s).unwrap_or_default(),
|
||||
Err(_) => Self::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the shell to spawn for a plain panel.
|
||||
///
|
||||
/// Order: `SPACESH_SHELL` env → `config.toml` `default_shell` → login shell
|
||||
/// from the passwd DB → `$SHELL` → `/bin/sh`. The passwd lookup matters under
|
||||
/// launchd, where `$SHELL` is typically absent (so a bash fallback would win).
|
||||
pub fn default_shell() -> String {
|
||||
if let Ok(s) = std::env::var("SPACESH_SHELL") {
|
||||
if !s.is_empty() { return s; }
|
||||
}
|
||||
if let Some(s) = Config::load().default_shell {
|
||||
if !s.is_empty() { return s; }
|
||||
}
|
||||
if let Some(s) = login_shell() {
|
||||
return s;
|
||||
}
|
||||
if let Ok(s) = std::env::var("SHELL") {
|
||||
if !s.is_empty() { return s; }
|
||||
}
|
||||
"/bin/sh".into()
|
||||
}
|
||||
|
||||
/// The current user's login shell from the passwd database (`getpwuid`).
|
||||
#[cfg(unix)]
|
||||
fn login_shell() -> Option<String> {
|
||||
use std::ffi::CStr;
|
||||
// SAFETY: getpwuid returns a pointer into a static buffer valid until the
|
||||
// next libc passwd call; we copy the string out immediately on this thread.
|
||||
unsafe {
|
||||
let pw = libc::getpwuid(libc::getuid());
|
||||
if pw.is_null() { return None; }
|
||||
let shell = (*pw).pw_shell;
|
||||
if shell.is_null() { return None; }
|
||||
let s = CStr::from_ptr(shell).to_str().ok()?;
|
||||
if s.is_empty() { None } else { Some(s.to_string()) }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn login_shell() -> Option<String> { None }
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_file_is_default() {
|
||||
let c = Config::from_path(Path::new("/no/such/spacesh/config.toml"));
|
||||
assert!(c.default_shell.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_default_shell() {
|
||||
let dir = std::env::temp_dir().join("spacesh-cfg-test");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("config.toml");
|
||||
std::fs::write(&path, "default_shell = \"/bin/zsh\"\n").unwrap();
|
||||
let c = Config::from_path(&path);
|
||||
assert_eq!(c.default_shell.as_deref(), Some("/bin/zsh"));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_wins() {
|
||||
let _serial = crate::test_support::serial();
|
||||
std::env::set_var("SPACESH_SHELL", "/tmp/fake-shell");
|
||||
let s = default_shell();
|
||||
std::env::remove_var("SPACESH_SHELL");
|
||||
assert_eq!(s, "/tmp/fake-shell");
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
mod config;
|
||||
mod event_log;
|
||||
mod event_store;
|
||||
mod hooks;
|
||||
|
||||
@@ -298,7 +298,7 @@ async fn handle_request(
|
||||
let _ = out.send(err(id, "NOT_FOUND", "workspace")).await; return;
|
||||
};
|
||||
let sid = reg.new_surface_id();
|
||||
let shell = command.clone().unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()));
|
||||
let shell = command.clone().unwrap_or_else(crate::config::default_shell);
|
||||
let spec = SurfaceSpec {
|
||||
command: shell, args: args.clone(), cwd: ws.path.clone(),
|
||||
agent_label: command, cols, rows, autostart: false,
|
||||
@@ -333,7 +333,7 @@ async fn handle_request(
|
||||
};
|
||||
let ws = reg.workspace(&ws_id).cloned().unwrap();
|
||||
let new_sid = reg.new_surface_id();
|
||||
let shell = command.clone().unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()));
|
||||
let shell = command.clone().unwrap_or_else(crate::config::default_shell);
|
||||
let spec = SurfaceSpec { command: shell, args, cwd: ws.path.clone(), agent_label: command, cols: 80, rows: 24, autostart: false };
|
||||
let (env, hooks_active) = spawn_env(&new_sid, &spec);
|
||||
match crate::surface::spawn_from_spec(new_sid.clone(), ws_id.clone(), &spec, env, hooks_active, state_tx.clone(), exit_tx.clone()) {
|
||||
@@ -406,7 +406,7 @@ async fn handle_request(
|
||||
let slot = slots.get(i);
|
||||
let new_sid = reg.new_surface_id();
|
||||
let command = slot.and_then(|s| s.command.clone());
|
||||
let shell = command.clone().unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()));
|
||||
let shell = command.clone().unwrap_or_else(crate::config::default_shell);
|
||||
let args = slot.map(|s| s.args.clone()).unwrap_or_default();
|
||||
let spec = SurfaceSpec { command: shell, args, cwd: ws.path.clone(), agent_label: command, cols: 80, rows: 24, autostart: false };
|
||||
let (env, hooks_active) = spawn_env(&new_sid, &spec);
|
||||
|
||||
Reference in New Issue
Block a user