1use std::net::TcpListener;
9use std::path::{Path, PathBuf};
10
11use anyhow::{Result, bail};
12use soma_config::{AuthMode, Config, default_data_dir, env_registry};
13use soma_runtime::server::resolve_auth_policy_kind;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum SetupCommand {
19 Check,
20 Repair,
21 Install,
24 PluginHook {
25 no_repair: bool,
26 },
27}
28
29pub fn apply_plugin_options() {
38 for (opt, dest) in env_registry::plugin_option_mappings() {
39 if let Some(v) = std::env::var_os(opt) {
40 let s = v.to_string_lossy();
41 if s.is_empty() || s.contains('\n') || s.contains('\r') {
42 continue;
43 }
44 unsafe { std::env::set_var(dest, v) };
47 }
48 }
49}
50
51pub async fn run_setup(config: &Config, command: SetupCommand) -> Result<()> {
52 let report = match command {
53 SetupCommand::Check => setup_check(config, true),
54 SetupCommand::Repair => setup_repair(config)?,
55 SetupCommand::Install => {
56 let dest = install_self()?;
57 println!("installed {} -> {}", env!("CARGO_PKG_NAME"), dest.display());
58 return Ok(());
59 }
60 SetupCommand::PluginHook { no_repair } => setup_plugin_hook(config, no_repair)?,
61 };
62
63 println!("{}", serde_json::to_string_pretty(&report)?);
64 if !report.blocking_failures.is_empty() {
65 bail!(
66 "setup found {} blocking failure(s)",
67 report.blocking_failures.len()
68 );
69 }
70 Ok(())
71}
72
73#[derive(Debug, serde::Serialize)]
76struct SetupFailure {
77 code: &'static str,
78 message: String,
79}
80
81#[derive(Debug, serde::Serialize)]
82struct SetupReport {
83 exit_policy: &'static str,
84 ran_repair: bool,
85 no_repair: bool,
86 blocking_failures: Vec<SetupFailure>,
87 advisory_failures: Vec<SetupFailure>,
88}
89
90impl SetupReport {
91 fn new(no_repair: bool) -> Self {
92 Self {
93 exit_policy: "success",
94 ran_repair: false,
95 no_repair,
96 blocking_failures: Vec::new(),
97 advisory_failures: Vec::new(),
98 }
99 }
100
101 fn finish(mut self) -> Self {
102 self.exit_policy = if !self.blocking_failures.is_empty() {
103 "blocking_failure"
104 } else if !self.advisory_failures.is_empty() {
105 "advisory_failure"
106 } else {
107 "success"
108 };
109 self
110 }
111}
112
113fn setup_plugin_hook(config: &Config, no_repair: bool) -> Result<SetupReport> {
116 if let Err(e) = install_self() {
120 eprintln!("setup plugin-hook: self-install skipped: {e}");
121 }
122 let initial = setup_check(config, no_repair);
123 if initial.blocking_failures.is_empty() || no_repair {
124 return Ok(initial);
125 }
126 setup_repair(config)
127}
128
129fn install_self() -> Result<PathBuf> {
137 let exe = std::env::current_exe()?;
138 let name = exe
139 .file_name()
140 .ok_or_else(|| anyhow::anyhow!("cannot determine binary name from {}", exe.display()))?;
141 let home =
142 dirs::home_dir().ok_or_else(|| anyhow::anyhow!("cannot determine home directory"))?;
143 let bin_dir = home.join(".local").join("bin");
144 std::fs::create_dir_all(&bin_dir)?;
145 let dest = bin_dir.join(name);
146
147 if dest == exe {
149 return Ok(dest);
150 }
151
152 let tmp = bin_dir.join(format!(".{}.tmp", name.to_string_lossy()));
153 std::fs::copy(&exe, &tmp)?;
154 #[cfg(unix)]
155 {
156 use std::os::unix::fs::PermissionsExt;
157 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))?;
158 }
159 std::fs::rename(&tmp, &dest).inspect_err(|_| {
160 let _ = std::fs::remove_file(&tmp);
161 })?;
162
163 let on_path = std::env::var_os("PATH")
165 .map(|p| std::env::split_paths(&p).any(|d| d == bin_dir))
166 .unwrap_or(false);
167 if !on_path {
168 eprintln!(
169 "note: {} is not on your PATH; add: export PATH=\"$HOME/.local/bin:$PATH\"",
170 bin_dir.display()
171 );
172 }
173 Ok(dest)
174}
175
176fn setup_check(config: &Config, no_repair: bool) -> SetupReport {
177 let mut report = SetupReport::new(no_repair);
178 let data_dir = match setup_data_dir() {
179 Ok(d) => d,
180 Err(e) => {
181 report.blocking_failures.push(SetupFailure {
182 code: "appdata_dir_unknown",
183 message: format!(
184 "cannot determine appdata directory: {e} — set HOME or RUNNING_IN_CONTAINER=1"
185 ),
186 });
187 return report.finish();
188 }
189 };
190
191 if !data_dir.is_dir() {
192 report.blocking_failures.push(SetupFailure {
193 code: "appdata_missing",
194 message: format!("appdata directory does not exist: {}", data_dir.display()),
195 });
196 }
197 let env_path = data_dir.join(".env");
198 if !env_path.is_file() {
199 report.advisory_failures.push(SetupFailure {
200 code: "env_file_missing",
201 message: format!(
202 "{} does not exist; setup repair will create one, but process env can supply values",
203 env_path.display()
204 ),
205 });
206 }
207 if config.soma.api_url.is_empty() {
208 report.blocking_failures.push(SetupFailure {
209 code: "missing_soma_api_url",
210 message: "SOMA_API_URL is required".into(),
211 });
212 }
213 if config.soma.api_key.is_empty() {
214 report.blocking_failures.push(SetupFailure {
215 code: "missing_soma_api_key",
216 message: "SOMA_API_KEY is required".into(),
217 });
218 }
219
220 check_auth(config, &mut report);
221 check_port(&config.mcp.host, config.mcp.port, &mut report);
222
223 report.finish()
224}
225
226fn setup_repair(config: &Config) -> Result<SetupReport> {
227 let data_dir = setup_data_dir()?;
231 std::fs::create_dir_all(&data_dir)?;
232 write_env(&data_dir, config)?;
233
234 let mut report = setup_check(config, false);
237 report.ran_repair = true;
238
239 Ok(report.finish())
240}
241
242fn require_oauth_field(
243 report: &mut SetupReport,
244 value: &Option<String>,
245 code: &'static str,
246 message: &str,
247) {
248 if value.as_deref().unwrap_or("").is_empty() {
249 report.blocking_failures.push(SetupFailure {
250 code,
251 message: message.into(),
252 });
253 }
254}
255
256fn check_auth(config: &Config, report: &mut SetupReport) {
257 if let Err(error) = resolve_auth_policy_kind(config, config.mcp.trusted_gateway) {
258 let code = if error.to_string().contains("SOMA_MCP_TRACE_HEADERS") {
259 "invalid_trace_headers_trust"
260 } else {
261 "invalid_auth_policy"
262 };
263 report.blocking_failures.push(SetupFailure {
264 code,
265 message: error.to_string(),
266 });
267 return;
268 }
269
270 if config.mcp.no_auth {
271 return;
272 }
273
274 if config.mcp.auth.mode == AuthMode::OAuth {
275 require_oauth_field(
276 report,
277 &config.mcp.auth.public_url,
278 "missing_oauth_public_url",
279 "SOMA_MCP_PUBLIC_URL is required for OAuth mode",
280 );
281 require_oauth_field(
282 report,
283 &config.mcp.auth.google_client_id,
284 "missing_oauth_client_id",
285 "SOMA_MCP_GOOGLE_CLIENT_ID is required for OAuth mode",
286 );
287 require_oauth_field(
288 report,
289 &config.mcp.auth.google_client_secret,
290 "missing_oauth_client_secret",
291 "SOMA_MCP_GOOGLE_CLIENT_SECRET is required for OAuth mode",
292 );
293 require_oauth_field(
294 report,
295 &Some(config.mcp.auth.admin_email.clone()),
296 "missing_oauth_admin_email",
297 "SOMA_MCP_AUTH_ADMIN_EMAIL is required for OAuth mode",
298 );
299 } else if config.mcp.api_token.as_deref().unwrap_or("").is_empty() {
300 report.blocking_failures.push(SetupFailure {
301 code: "missing_mcp_token",
302 message: "SOMA_MCP_TOKEN is required unless no_auth or OAuth mode is enabled".into(),
303 });
304 }
305}
306
307fn check_port(host: &str, port: u16, report: &mut SetupReport) {
308 if TcpListener::bind((host, port)).is_err() {
309 report.advisory_failures.push(SetupFailure {
310 code: "mcp_port_in_use",
311 message: format!("MCP bind address {host}:{port} is already in use or unavailable"),
312 });
313 }
314}
315
316fn setup_data_dir() -> anyhow::Result<PathBuf> {
317 if let Some(val) = std::env::var_os("SOMA_HOME") {
326 return Ok(PathBuf::from(val));
327 }
328 default_data_dir()
329}
330
331fn write_env(data_dir: &Path, config: &Config) -> Result<()> {
332 let mut lines = vec![
333 dotenv_assignment("SOMA_API_URL", &config.soma.api_url)?,
334 dotenv_assignment("SOMA_API_KEY", &config.soma.api_key)?,
335 dotenv_assignment("SOMA_MCP_HOST", &config.mcp.host)?,
336 dotenv_assignment("SOMA_MCP_PORT", &config.mcp.port.to_string())?,
337 dotenv_assignment("SOMA_MCP_NO_AUTH", &config.mcp.no_auth.to_string())?,
338 dotenv_assignment(
339 "SOMA_MCP_TRACE_HEADERS",
340 &config.mcp.trace_headers.to_string(),
341 )?,
342 ];
343
344 if let Some(token) = config.mcp.api_token.as_deref().filter(|v| !v.is_empty()) {
345 lines.push(dotenv_assignment("SOMA_MCP_TOKEN", token)?);
346 }
347 if config.mcp.auth.mode == AuthMode::OAuth {
348 lines.push("SOMA_MCP_AUTH_MODE=oauth".into());
349 if let Some(v) = &config.mcp.auth.public_url {
350 lines.push(dotenv_assignment("SOMA_MCP_PUBLIC_URL", v)?);
351 }
352 if let Some(v) = &config.mcp.auth.google_client_id {
353 lines.push(dotenv_assignment("SOMA_MCP_GOOGLE_CLIENT_ID", v)?);
354 }
355 if let Some(v) = &config.mcp.auth.google_client_secret {
356 lines.push(dotenv_assignment("SOMA_MCP_GOOGLE_CLIENT_SECRET", v)?);
357 }
358 if !config.mcp.auth.admin_email.is_empty() {
359 lines.push(dotenv_assignment(
360 "SOMA_MCP_AUTH_ADMIN_EMAIL",
361 &config.mcp.auth.admin_email,
362 )?);
363 }
364 }
365
366 let env_path = data_dir.join(".env");
367 let temp_path = data_dir.join(".env.tmp");
368 #[cfg(unix)]
369 {
370 use std::io::Write;
371 use std::os::unix::fs::OpenOptionsExt;
372
373 let mut file = std::fs::OpenOptions::new()
375 .create(true)
376 .truncate(true)
377 .write(true)
378 .mode(0o600)
379 .open(&temp_path)?;
380 writeln!(file, "{}", lines.join("\n"))?;
381 file.sync_all()?;
382 }
383 #[cfg(not(unix))]
384 std::fs::write(&temp_path, format!("{}\n", lines.join("\n")))?;
385 std::fs::rename(&temp_path, &env_path).inspect_err(|_| {
386 let _ = std::fs::remove_file(&temp_path);
389 })?;
390 Ok(())
391}
392
393fn dotenv_assignment(key: &'static str, value: &str) -> Result<String> {
394 Ok(format!("{key}={}", dotenv_value(value)?))
395}
396
397fn dotenv_value(value: &str) -> Result<String> {
398 if value.chars().any(|c| matches!(c, '\n' | '\r' | '\0')) {
399 bail!("dotenv values cannot contain newlines or NUL bytes");
400 }
401
402 if value.chars().all(|c| {
403 c.is_ascii_alphanumeric()
404 || matches!(c, '_' | '-' | '.' | '/' | ':' | '@' | '%' | '+' | '=' | ',')
405 }) {
406 return Ok(value.to_string());
407 }
408
409 let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
410 Ok(format!("\"{escaped}\""))
411}
412
413#[cfg(test)]
414#[path = "setup_tests.rs"]
415mod tests;