Skip to main content

soma_cli/
setup.rs

1//! Plugin setup and repair commands.
2//!
3//! These are operational commands that check and mutate appdata, write .env
4//! files, and validate auth/port configuration before the server starts.
5//! Business logic stays in `app.rs`; this module is allowed to touch the
6//! filesystem and network only for diagnostics and setup purposes.
7
8use 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// ── public surface ────────────────────────────────────────────────────────────
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum SetupCommand {
19    Check,
20    Repair,
21    /// Copy this binary into ~/.local/bin so it is available as a bare command
22    /// in the user's own terminal, independent of Claude Code.
23    Install,
24    PluginHook {
25        no_repair: bool,
26    },
27}
28
29/// Translate Claude Code plugin options (`CLAUDE_PLUGIN_OPTION_*`) into the
30/// `SOMA_*` process env vars the binary reads, before `Config::load()` runs.
31///
32/// This replaces the former `plugin-setup.sh` wrapper: the binary now owns the
33/// env-var mapping itself, so the plugin hook can invoke the binary directly.
34/// Values containing newlines/CR are rejected (skipped) to mirror the script's
35/// `reject_unsafe_value` guard. Only applied on the plugin-hook path, matching
36/// the script's scope.
37pub 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            // edition 2021: set_var is safe (no unsafe block required).
45            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// ── internal types ────────────────────────────────────────────────────────────
73
74#[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
112// ── setup logic ───────────────────────────────────────────────────────────────
113
114fn setup_plugin_hook(config: &Config, no_repair: bool) -> Result<SetupReport> {
115    // Keep the user's terminal copy in ~/.local/bin fresh each session so it
116    // survives `/plugin update` (which changes the cache path). Best-effort:
117    // never fail the hook over a self-install problem.
118    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
128/// Copy the running binary into `~/.local/bin/<name>` so it is callable as a
129/// bare command in the user's own terminal, independent of Claude Code.
130///
131/// Uses the running executable's own file name as the destination, so this
132/// function is identical across every server repo. Copy (not symlink) so it
133/// survives `/plugin update`, which changes the plugin cache path a symlink
134/// would otherwise dangle to. Atomic via temp + rename; idempotent.
135fn 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    // Running the already-installed copy: nothing to do.
147    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    // Advisory: warn (not fail) if ~/.local/bin is not on PATH.
163    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    // L8: Two concurrent `setup repair` invocations can clobber each other's
227    // .env.tmp → .env rename. For a plugin hook triggered by a single Claude
228    // session this is benign, but a proper fix would use flock(2) on the data dir.
229    let data_dir = setup_data_dir()?;
230    std::fs::create_dir_all(&data_dir)?;
231    write_env(&data_dir, config)?;
232
233    // Re-run check after repair; `appdata_missing` is now resolved since
234    // `create_dir_all` succeeded above.
235    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    // Writes go to the canonical service appdata dir — `~/.<service>/` on bare
317    // metal, `/data` in a container (see `default_data_dir`). This is the SAME
318    // location the binary loads `.env` from at startup (`config::load_dotenv`),
319    // so the plugin hook's writes and the server's reads always agree.
320    //
321    // An explicit `SOMA_HOME` override is honored (used by tests).
322    // `CLAUDE_PLUGIN_DATA` is intentionally NOT consulted: the plugin's sandboxed
323    // data dir must not diverge from `~/.<service>/`.
324    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        // mode(0o600) sets permissions atomically at creation — no second chmod needed.
373        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        // Best-effort cleanup: remove the temp file if rename fails to avoid leaking
386        // a partially-written file containing secrets.
387        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;