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:
@@ -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