opencode_cloud/wizard/
mod.rs

1//! Interactive setup wizard
2//!
3//! Guides users through first-time configuration with interactive prompts.
4
5mod auth;
6mod network;
7mod prechecks;
8mod summary;
9
10pub use auth::create_container_user;
11pub use prechecks::{verify_docker_available, verify_tty};
12
13use anyhow::{Result, anyhow};
14use console::{Term, style};
15use dialoguer::Confirm;
16use opencode_cloud_core::Config;
17use opencode_cloud_core::docker::{CONTAINER_NAME, DockerClient, container_is_running};
18
19use auth::prompt_auth;
20use network::{prompt_hostname, prompt_port};
21use summary::display_summary;
22
23/// Wizard state holding collected configuration values
24#[derive(Debug, Clone)]
25pub struct WizardState {
26    /// Username for authentication
27    pub auth_username: Option<String>,
28    /// Password for authentication
29    pub auth_password: Option<String>,
30    /// Port for the web UI
31    pub port: u16,
32    /// Bind address (localhost or 0.0.0.0)
33    pub bind: String,
34}
35
36impl WizardState {
37    /// Apply wizard state to a Config struct
38    pub fn apply_to_config(&self, config: &mut Config) {
39        if let Some(ref username) = self.auth_username {
40            config.auth_username = Some(username.clone());
41        }
42        if let Some(ref password) = self.auth_password {
43            config.auth_password = Some(password.clone());
44        }
45        config.opencode_web_port = self.port;
46        config.bind = self.bind.clone();
47    }
48}
49
50/// Handle Ctrl+C during wizard by restoring cursor and returning error
51fn handle_interrupt() -> anyhow::Error {
52    // Restore cursor in case it was hidden
53    let _ = Term::stdout().show_cursor();
54    anyhow!("Setup cancelled")
55}
56
57/// Run the interactive setup wizard
58///
59/// Guides the user through configuration, collecting values and returning
60/// a complete Config. Does NOT save - the caller is responsible for saving.
61///
62/// Creates PAM-based users in the container if it's running.
63/// Migrates old auth_username/auth_password to new users array.
64///
65/// # Arguments
66/// * `existing_config` - Optional existing config to show current values
67///
68/// # Returns
69/// * `Ok(Config)` - Completed configuration ready to save
70/// * `Err` - User cancelled or prechecks failed
71pub async fn run_wizard(existing_config: Option<&Config>) -> Result<Config> {
72    // 1. Prechecks
73    verify_tty()?;
74    verify_docker_available().await?;
75
76    // Connect to Docker for container operations
77    let client = DockerClient::new()?;
78    let is_container_running = container_is_running(&client, CONTAINER_NAME)
79        .await
80        .unwrap_or(false);
81
82    println!();
83    println!("{}", style("opencode-cloud Setup Wizard").cyan().bold());
84    println!("{}", style("=".repeat(30)).dim());
85    println!();
86
87    // 2. If existing config with users configured, show current summary and ask to reconfigure
88    if let Some(config) = existing_config {
89        let has_users = !config.users.is_empty();
90        let has_old_auth = config.has_required_auth();
91
92        if has_users || has_old_auth {
93            println!("{}", style("Current configuration:").bold());
94            if has_users {
95                println!("  Users:    {}", config.users.join(", "));
96            } else if has_old_auth {
97                println!(
98                    "  Username: {} (legacy)",
99                    config.auth_username.as_deref().unwrap_or("-")
100                );
101                println!("  Password: ********");
102            }
103            println!("  Port:     {}", config.opencode_web_port);
104            println!("  Binding:  {}", config.bind);
105            println!();
106
107            let reconfigure = Confirm::new()
108                .with_prompt("Reconfigure?")
109                .default(false)
110                .interact()
111                .map_err(|_| handle_interrupt())?;
112
113            if !reconfigure {
114                return Err(anyhow!("Setup cancelled"));
115            }
116            println!();
117        }
118    }
119
120    // 3. Quick setup offer
121    let quick = Confirm::new()
122        .with_prompt("Use defaults for everything except credentials?")
123        .default(false)
124        .interact()
125        .map_err(|_| handle_interrupt())?;
126
127    println!();
128
129    // 4. Collect values
130    let total_steps = if quick { 1 } else { 3 };
131
132    let (username, password) = prompt_auth(1, total_steps)?;
133
134    let (port, bind) = if quick {
135        (3000, "localhost".to_string())
136    } else {
137        let port = prompt_port(2, total_steps, 3000)?;
138        let bind = prompt_hostname(3, total_steps, "localhost")?;
139        (port, bind)
140    };
141
142    let state = WizardState {
143        auth_username: Some(username.clone()),
144        auth_password: Some(password.clone()),
145        port,
146        bind,
147    };
148
149    // 5. Summary
150    println!();
151    display_summary(&state);
152    println!();
153
154    // 6. Confirm save
155    let save = Confirm::new()
156        .with_prompt("Save this configuration?")
157        .default(true)
158        .interact()
159        .map_err(|_| handle_interrupt())?;
160
161    if !save {
162        return Err(anyhow!("Setup cancelled"));
163    }
164
165    // 7. Create user in container if running
166    if is_container_running {
167        println!();
168        println!("{}", style("Creating user in container...").cyan());
169        auth::create_container_user(&client, &username, &password).await?;
170    } else {
171        println!();
172        println!(
173            "{}",
174            style("Note: User will be created when container starts.").dim()
175        );
176    }
177
178    // 8. Build and return config
179    let mut config = existing_config.cloned().unwrap_or_default();
180    state.apply_to_config(&mut config);
181
182    // Update config.users array (PAM-based auth tracking)
183    if !config.users.contains(&username) {
184        config.users.push(username);
185    }
186
187    // Migrate old auth_username/auth_password if present
188    if let Some(ref old_username) = config.auth_username {
189        if !old_username.is_empty() && !config.users.contains(old_username) {
190            println!(
191                "{}",
192                style(format!(
193                    "Migrating existing user '{old_username}' to PAM-based authentication..."
194                ))
195                .dim()
196            );
197            config.users.push(old_username.clone());
198        }
199    }
200
201    // Clear legacy auth fields (keep them empty for schema compatibility)
202    config.auth_username = Some(String::new());
203    config.auth_password = Some(String::new());
204
205    Ok(config)
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn test_wizard_state_apply_to_config() {
214        let state = WizardState {
215            auth_username: Some("testuser".to_string()),
216            auth_password: Some("testpass".to_string()),
217            port: 8080,
218            bind: "0.0.0.0".to_string(),
219        };
220
221        let mut config = Config::default();
222        state.apply_to_config(&mut config);
223
224        assert_eq!(config.auth_username, Some("testuser".to_string()));
225        assert_eq!(config.auth_password, Some("testpass".to_string()));
226        assert_eq!(config.opencode_web_port, 8080);
227        assert_eq!(config.bind, "0.0.0.0");
228    }
229
230    #[test]
231    fn test_wizard_state_preserves_other_config_fields() {
232        let state = WizardState {
233            auth_username: Some("admin".to_string()),
234            auth_password: Some("secret".to_string()),
235            port: 3000,
236            bind: "localhost".to_string(),
237        };
238
239        let mut config = Config {
240            auto_restart: false,
241            restart_retries: 10,
242            ..Config::default()
243        };
244        state.apply_to_config(&mut config);
245
246        // Should preserve existing fields
247        assert!(!config.auto_restart);
248        assert_eq!(config.restart_retries, 10);
249
250        // Should update wizard fields
251        assert_eq!(config.auth_username, Some("admin".to_string()));
252    }
253}