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::{Result, bail};
12use soma_config::{AuthMode, Config, default_data_dir, env_registry};
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            // FIXME: Audit that the environment access only happens in single-threaded code.
46            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// ── internal types ────────────────────────────────────────────────────────────
74
75#[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
113// ── setup logic ───────────────────────────────────────────────────────────────
114
115fn setup_plugin_hook(config: &Config, no_repair: bool) -> Result<SetupReport> {
116    // Keep the user's terminal copy in ~/.local/bin fresh each session so it
117    // survives `/plugin update` (which changes the cache path). Best-effort:
118    // never fail the hook over a self-install problem.
119    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
129/// Copy the running binary into `~/.local/bin/<name>` so it is callable as a
130/// bare command in the user's own terminal, independent of Claude Code.
131///
132/// Uses the running executable's own file name as the destination, so this
133/// function is identical across every server repo. Copy (not symlink) so it
134/// survives `/plugin update`, which changes the plugin cache path a symlink
135/// would otherwise dangle to. Atomic via temp + rename; idempotent.
136fn 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    // Running the already-installed copy: nothing to do.
148    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    // Advisory: warn (not fail) if ~/.local/bin is not on PATH.
164    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    // L8: Two concurrent `setup repair` invocations can clobber each other's
228    // .env.tmp → .env rename. For a plugin hook triggered by a single Claude
229    // session this is benign, but a proper fix would use flock(2) on the data dir.
230    let data_dir = setup_data_dir()?;
231    std::fs::create_dir_all(&data_dir)?;
232    write_env(&data_dir, config)?;
233
234    // Re-run check after repair; `appdata_missing` is now resolved since
235    // `create_dir_all` succeeded above.
236    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    // Writes go to the canonical service appdata dir — `~/.<service>/` on bare
318    // metal, `/data` in a container (see `default_data_dir`). This is the SAME
319    // location the binary loads `.env` from at startup (`config::load_dotenv`),
320    // so the plugin hook's writes and the server's reads always agree.
321    //
322    // An explicit `SOMA_HOME` override is honored (used by tests).
323    // `CLAUDE_PLUGIN_DATA` is intentionally NOT consulted: the plugin's sandboxed
324    // data dir must not diverge from `~/.<service>/`.
325    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        // mode(0o600) sets permissions atomically at creation — no second chmod needed.
374        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        // Best-effort cleanup: remove the temp file if rename fails to avoid leaking
387        // a partially-written file containing secrets.
388        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;