Skip to main content

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    /// Image source preference: "prebuilt" or "build"
35    pub image_source: String,
36}
37
38impl WizardState {
39    /// Apply wizard state to a Config struct
40    pub fn apply_to_config(&self, config: &mut Config) {
41        if let Some(ref username) = self.auth_username {
42            config.auth_username = Some(username.clone());
43        }
44        if let Some(ref password) = self.auth_password {
45            config.auth_password = Some(password.clone());
46        }
47        config.opencode_web_port = self.port;
48        config.bind = self.bind.clone();
49        config.image_source = self.image_source.clone();
50    }
51}
52
53/// Handle Ctrl+C during wizard by restoring cursor and returning error
54fn handle_interrupt() -> anyhow::Error {
55    // Restore cursor in case it was hidden
56    let _ = Term::stdout().show_cursor();
57    anyhow!("Setup cancelled")
58}
59
60/// Prompt user to choose image source
61fn prompt_image_source(step: usize, total: usize) -> Result<String> {
62    println!(
63        "{}",
64        style(format!("Step {step}/{total}: Image Source"))
65            .cyan()
66            .bold()
67    );
68    println!();
69    println!("How would you like to get the Docker image?");
70    println!();
71    println!("  {} Pull prebuilt image (~2 minutes)", style("[1]").bold());
72    println!("      Download from GitHub Container Registry");
73    println!("      Fast, verified builds published automatically");
74    println!();
75    println!(
76        "  {} Build from source (30-60 minutes)",
77        style("[2]").bold()
78    );
79    println!("      Compile everything locally");
80    println!("      Full transparency, customizable Dockerfile");
81    println!();
82    println!(
83        "{}",
84        style("Build history: https://github.com/pRizz/opencode-cloud/actions").dim()
85    );
86    println!();
87
88    let options = vec!["Pull prebuilt image (recommended)", "Build from source"];
89
90    let selection = dialoguer::Select::new()
91        .with_prompt("Select image source")
92        .items(&options)
93        .default(0)
94        .interact()
95        .map_err(|_| handle_interrupt())?;
96
97    println!();
98
99    Ok(if selection == 0 { "prebuilt" } else { "build" }.to_string())
100}
101
102/// Run the interactive setup wizard
103///
104/// Guides the user through configuration, collecting values and returning
105/// a complete Config. Does NOT save - the caller is responsible for saving.
106///
107/// Creates PAM-based users in the container if it's running.
108/// Migrates old auth_username/auth_password to new users array.
109///
110/// # Arguments
111/// * `existing_config` - Optional existing config to show current values
112///
113/// # Returns
114/// * `Ok(Config)` - Completed configuration ready to save
115/// * `Err` - User cancelled or prechecks failed
116pub async fn run_wizard(existing_config: Option<&Config>) -> Result<Config> {
117    // 1. Prechecks
118    verify_tty()?;
119    verify_docker_available().await?;
120
121    // Connect to Docker for container operations
122    let client = DockerClient::new()?;
123    let is_container_running = container_is_running(&client, CONTAINER_NAME)
124        .await
125        .unwrap_or(false);
126
127    println!();
128    println!("{}", style("opencode-cloud Setup Wizard").cyan().bold());
129    println!("{}", style("=".repeat(30)).dim());
130    println!();
131
132    // 2. If existing config with users configured, show current summary and ask to reconfigure
133    if let Some(config) = existing_config {
134        let has_users = !config.users.is_empty();
135        let has_old_auth = config.has_required_auth();
136
137        if has_users || has_old_auth {
138            println!("{}", style("Current configuration:").bold());
139            if has_users {
140                println!("  Users:    {}", config.users.join(", "));
141            } else if has_old_auth {
142                println!(
143                    "  Username: {} (legacy)",
144                    config.auth_username.as_deref().unwrap_or("-")
145                );
146                println!("  Password: ********");
147            }
148            println!("  Port:     {}", config.opencode_web_port);
149            println!("  Binding:  {}", config.bind);
150            println!();
151
152            let reconfigure = Confirm::new()
153                .with_prompt("Reconfigure?")
154                .default(false)
155                .interact()
156                .map_err(|_| handle_interrupt())?;
157
158            if !reconfigure {
159                return Err(anyhow!("Setup cancelled"));
160            }
161            println!();
162        }
163    }
164
165    // 3. Quick setup offer
166    let quick = Confirm::new()
167        .with_prompt("Use defaults for everything except credentials?")
168        .default(false)
169        .interact()
170        .map_err(|_| handle_interrupt())?;
171
172    println!();
173
174    // 4. Collect values
175    let total_steps = if quick { 2 } else { 4 };
176
177    let (username, password) = prompt_auth(1, total_steps)?;
178    let image_source = prompt_image_source(2, total_steps)?;
179
180    let (port, bind) = if quick {
181        (3000, "localhost".to_string())
182    } else {
183        let port = prompt_port(3, total_steps, 3000)?;
184        let bind = prompt_hostname(4, total_steps, "localhost")?;
185        (port, bind)
186    };
187
188    let state = WizardState {
189        auth_username: Some(username.clone()),
190        auth_password: Some(password.clone()),
191        port,
192        bind,
193        image_source,
194    };
195
196    // 5. Summary
197    println!();
198    display_summary(&state);
199    println!();
200
201    // 6. Confirm save
202    let save = Confirm::new()
203        .with_prompt("Save this configuration?")
204        .default(true)
205        .interact()
206        .map_err(|_| handle_interrupt())?;
207
208    if !save {
209        return Err(anyhow!("Setup cancelled"));
210    }
211
212    // 7. Create user in container if running
213    if is_container_running {
214        println!();
215        println!("{}", style("Creating user in container...").cyan());
216        auth::create_container_user(&client, &username, &password).await?;
217    } else {
218        println!();
219        println!(
220            "{}",
221            style("Note: User will be created when container starts.").dim()
222        );
223    }
224
225    // 8. Build and return config
226    let mut config = existing_config.cloned().unwrap_or_default();
227    state.apply_to_config(&mut config);
228
229    // Update config.users array (PAM-based auth tracking)
230    if !config.users.contains(&username) {
231        config.users.push(username);
232    }
233
234    // Migrate old auth_username/auth_password if present
235    if let Some(ref old_username) = config.auth_username {
236        if !old_username.is_empty() && !config.users.contains(old_username) {
237            println!(
238                "{}",
239                style(format!(
240                    "Migrating existing user '{old_username}' to PAM-based authentication..."
241                ))
242                .dim()
243            );
244            config.users.push(old_username.clone());
245        }
246    }
247
248    // Clear legacy auth fields (keep them empty for schema compatibility)
249    config.auth_username = Some(String::new());
250    config.auth_password = Some(String::new());
251
252    Ok(config)
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn test_wizard_state_apply_to_config() {
261        let state = WizardState {
262            auth_username: Some("testuser".to_string()),
263            auth_password: Some("testpass".to_string()),
264            port: 8080,
265            bind: "0.0.0.0".to_string(),
266            image_source: "prebuilt".to_string(),
267        };
268
269        let mut config = Config::default();
270        state.apply_to_config(&mut config);
271
272        assert_eq!(config.auth_username, Some("testuser".to_string()));
273        assert_eq!(config.auth_password, Some("testpass".to_string()));
274        assert_eq!(config.opencode_web_port, 8080);
275        assert_eq!(config.bind, "0.0.0.0");
276        assert_eq!(config.image_source, "prebuilt");
277    }
278
279    #[test]
280    fn test_wizard_state_preserves_other_config_fields() {
281        let state = WizardState {
282            auth_username: Some("admin".to_string()),
283            auth_password: Some("secret".to_string()),
284            port: 3000,
285            bind: "localhost".to_string(),
286            image_source: "build".to_string(),
287        };
288
289        let mut config = Config {
290            auto_restart: false,
291            restart_retries: 10,
292            ..Config::default()
293        };
294        state.apply_to_config(&mut config);
295
296        // Should preserve existing fields
297        assert!(!config.auto_restart);
298        assert_eq!(config.restart_retries, 10);
299
300        // Should update wizard fields
301        assert_eq!(config.auth_username, Some("admin".to_string()));
302        assert_eq!(config.image_source, "build");
303    }
304}