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_config::write_private_atomic;
17use systemprompt_identifiers::ProviderId;
18use systemprompt_logging::CliService;
19
20use super::SetupArgs;
21use crate::CliConfig;
22use crate::interactive::Prompter;
23use crate::shared::generate_identity;
24use data::resolve_primary;
25use prompts::{resolve_interactive_primary, select_provider_keys};
26
27pub use data::SecretsData;
28
29#[doc(hidden)]
30pub fn collect_non_interactive(
31    args: &SetupArgs,
32    config: &CliConfig,
33) -> Result<(SecretsData, Option<ProviderId>)> {
34    if !config.is_json_output() {
35        CliService::section("Secrets Setup");
36    }
37
38    let identity = generate_identity()?;
39    if !config.is_json_output() {
40        CliService::success(
41            "Generated OAuth at-rest pepper, encryption master key, manifest signing seed and signing key",
42        );
43    }
44
45    let secrets = SecretsData {
46        oauth_at_rest_pepper: identity.oauth_at_rest_pepper,
47        encryption_master_key: identity.encryption_master_key,
48        manifest_signing_secret_seed: Some(identity.manifest_signing_secret_seed),
49        signing_key_pem: Some(identity.signing_key_pem),
50        database_url: None,
51        gemini: args.gemini_key.clone(),
52        anthropic: args.anthropic_key.clone(),
53        openai: args.openai_key.clone(),
54        github: args.github_token.clone(),
55    };
56
57    validate_secrets(&secrets)?;
58    let primary = resolve_primary(args, &secrets)?;
59
60    if !config.is_json_output() {
61        CliService::success(&format!("Configured keys: {}", secrets.summary()));
62    }
63
64    Ok((secrets, primary))
65}
66
67#[doc(hidden)]
68pub fn collect_interactive(
69    args: &SetupArgs,
70    prompter: &dyn Prompter,
71    env_name: &str,
72    _config: &CliConfig,
73) -> Result<(SecretsData, Option<ProviderId>)> {
74    CliService::section(&format!("Secrets Setup ({})", env_name));
75    CliService::info("At least one AI provider API key is required.");
76
77    let identity = generate_identity()?;
78    CliService::success(
79        "Generated OAuth at-rest pepper, encryption master key, manifest signing seed and signing key",
80    );
81
82    let mut secrets = SecretsData {
83        oauth_at_rest_pepper: identity.oauth_at_rest_pepper,
84        encryption_master_key: identity.encryption_master_key,
85        manifest_signing_secret_seed: Some(identity.manifest_signing_secret_seed),
86        signing_key_pem: Some(identity.signing_key_pem),
87        ..Default::default()
88    };
89
90    if args.has_ai_provider() {
91        args.gemini_key.clone_into(&mut secrets.gemini);
92        args.anthropic_key.clone_into(&mut secrets.anthropic);
93        args.openai_key.clone_into(&mut secrets.openai);
94        args.github_token.clone_into(&mut secrets.github);
95        CliService::success(&format!("Using provided keys: {}", secrets.summary()));
96        let primary = resolve_primary(args, &secrets)?;
97        return Ok((secrets, primary));
98    }
99
100    let explicit = select_provider_keys(prompter, &mut secrets)?;
101    validate_secrets(&secrets)?;
102    let primary = resolve_interactive_primary(prompter, explicit, &secrets)?;
103
104    CliService::success(&format!("Configured keys: {}", secrets.summary()));
105
106    Ok((secrets, primary))
107}
108
109fn validate_secrets(secrets: &SecretsData) -> Result<()> {
110    if secrets.oauth_at_rest_pepper.len() < 32 {
111        anyhow::bail!("OAuth at-rest pepper must be at least 32 characters");
112    }
113    systemprompt_config::decode_master_key(&secrets.encryption_master_key)?;
114
115    if !secrets.has_ai_provider() {
116        anyhow::bail!(
117            "At least one AI provider API key is required.\n\n\
118             Provide one of:\n\
119             --gemini-key <KEY>     Google AI (Gemini)\n\
120             --anthropic-key <KEY>  Anthropic (Claude)\n\
121             --openai-key <KEY>     OpenAI (GPT)\n\n\
122             Or set environment variables:\n\
123             GEMINI_API_KEY, ANTHROPIC_API_KEY, or OPENAI_API_KEY"
124        );
125    }
126
127    Ok(())
128}
129
130pub(super) fn save(secrets: &SecretsData, secrets_path: &Path) -> Result<()> {
131    let content = serde_json::to_string_pretty(secrets).context("Failed to serialize secrets")?;
132    if let Some(parent) = secrets_path.parent().filter(|p| !p.as_os_str().is_empty()) {
133        std::fs::create_dir_all(parent)
134            .with_context(|| format!("Failed to create {}", parent.display()))?;
135    }
136    write_private_atomic(secrets_path, content.as_bytes())
137        .with_context(|| format!("Failed to write {}", secrets_path.display()))?;
138
139    CliService::success(&format!("Saved secrets to {}", secrets_path.display()));
140
141    Ok(())
142}