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