Skip to main content

systemprompt_cli/commands/admin/setup/secrets/
mod.rs

1//! Secret collection and persistence for the setup wizard.
2//!
3//! The `collect_*` functions gather the OAuth at-rest pepper, database URL, and
4//! AI-provider keys interactively or from flags; `save` writes the file with
5//! `0600` permissions on Unix. The data model and default-provider resolution
6//! live in `data`; the interactive prompts live in [`prompts`].
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11mod data;
12pub mod prompts;
13
14use anyhow::{Context, Result};
15use std::path::Path;
16use systemprompt_identifiers::ProviderId;
17use systemprompt_logging::CliService;
18
19use super::SetupArgs;
20use crate::CliConfig;
21use crate::interactive::Prompter;
22use crate::shared::generate_identity;
23use data::resolve_primary;
24use prompts::{resolve_interactive_primary, select_provider_keys};
25
26pub use data::SecretsData;
27
28#[doc(hidden)]
29pub fn collect_non_interactive(
30    args: &SetupArgs,
31    config: &CliConfig,
32) -> Result<(SecretsData, Option<ProviderId>)> {
33    if !config.is_json_output() {
34        CliService::section("Secrets Setup");
35    }
36
37    let identity = generate_identity()?;
38    if !config.is_json_output() {
39        CliService::success(
40            "Generated OAuth at-rest pepper, manifest signing seed and signing key",
41        );
42    }
43
44    let secrets = SecretsData {
45        oauth_at_rest_pepper: identity.oauth_at_rest_pepper,
46        manifest_signing_secret_seed: Some(identity.manifest_signing_secret_seed),
47        signing_key_pem: Some(identity.signing_key_pem),
48        database_url: None,
49        gemini: args.gemini_key.clone(),
50        anthropic: args.anthropic_key.clone(),
51        openai: args.openai_key.clone(),
52        github: args.github_token.clone(),
53    };
54
55    validate_secrets(&secrets)?;
56    let primary = resolve_primary(args, &secrets)?;
57
58    if !config.is_json_output() {
59        CliService::success(&format!("Configured keys: {}", secrets.summary()));
60    }
61
62    Ok((secrets, primary))
63}
64
65#[doc(hidden)]
66pub fn collect_interactive(
67    args: &SetupArgs,
68    prompter: &dyn Prompter,
69    env_name: &str,
70    _config: &CliConfig,
71) -> Result<(SecretsData, Option<ProviderId>)> {
72    CliService::section(&format!("Secrets Setup ({})", env_name));
73    CliService::info("At least one AI provider API key is required.");
74
75    let identity = generate_identity()?;
76    CliService::success("Generated OAuth at-rest pepper, manifest signing seed and signing key");
77
78    let mut secrets = SecretsData {
79        oauth_at_rest_pepper: identity.oauth_at_rest_pepper,
80        manifest_signing_secret_seed: Some(identity.manifest_signing_secret_seed),
81        signing_key_pem: Some(identity.signing_key_pem),
82        ..Default::default()
83    };
84
85    if args.has_ai_provider() {
86        args.gemini_key.clone_into(&mut secrets.gemini);
87        args.anthropic_key.clone_into(&mut secrets.anthropic);
88        args.openai_key.clone_into(&mut secrets.openai);
89        args.github_token.clone_into(&mut secrets.github);
90        CliService::success(&format!("Using provided keys: {}", secrets.summary()));
91        let primary = resolve_primary(args, &secrets)?;
92        return Ok((secrets, primary));
93    }
94
95    let explicit = select_provider_keys(prompter, &mut secrets)?;
96    validate_secrets(&secrets)?;
97    let primary = resolve_interactive_primary(prompter, explicit, &secrets)?;
98
99    CliService::success(&format!("Configured keys: {}", secrets.summary()));
100
101    Ok((secrets, primary))
102}
103
104fn validate_secrets(secrets: &SecretsData) -> Result<()> {
105    if secrets.oauth_at_rest_pepper.len() < 32 {
106        anyhow::bail!("OAuth at-rest pepper must be at least 32 characters");
107    }
108
109    if !secrets.has_ai_provider() {
110        anyhow::bail!(
111            "At least one AI provider API key is required.\n\n\
112             Provide one of:\n\
113             --gemini-key <KEY>     Google AI (Gemini)\n\
114             --anthropic-key <KEY>  Anthropic (Claude)\n\
115             --openai-key <KEY>     OpenAI (GPT)\n\n\
116             Or set environment variables:\n\
117             GEMINI_API_KEY, ANTHROPIC_API_KEY, or OPENAI_API_KEY"
118        );
119    }
120
121    Ok(())
122}
123
124pub(super) fn save(secrets: &SecretsData, secrets_path: &Path) -> Result<()> {
125    if let Some(parent) = secrets_path.parent() {
126        std::fs::create_dir_all(parent)
127            .with_context(|| format!("Failed to create directory {}", parent.display()))?;
128    }
129
130    let content = serde_json::to_string_pretty(secrets).context("Failed to serialize secrets")?;
131
132    std::fs::write(secrets_path, content)
133        .with_context(|| format!("Failed to write {}", secrets_path.display()))?;
134
135    #[cfg(unix)]
136    {
137        use std::os::unix::fs::PermissionsExt;
138        let permissions = std::fs::Permissions::from_mode(0o600);
139        std::fs::set_permissions(secrets_path, permissions)
140            .with_context(|| format!("Failed to set permissions on {}", secrets_path.display()))?;
141    }
142
143    CliService::success(&format!("Saved secrets to {}", secrets_path.display()));
144
145    Ok(())
146}