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 config_view;
7mod network;
8mod prechecks;
9mod summary;
10
11pub use auth::create_container_user;
12pub use prechecks::{verify_docker_available, verify_tty};
13
14use anyhow::{Result, anyhow};
15use console::{Term, style};
16use dialoguer::Confirm;
17use opencode_cloud_core::docker::{CONTAINER_NAME, DockerClient, container_is_running};
18use opencode_cloud_core::{Config, config::default_mounts};
19
20use auth::prompt_auth;
21use config_view::render_config_snapshot;
22use network::{prompt_hostname, prompt_port};
23use summary::display_summary;
24
25/// Wizard state holding collected configuration values
26#[derive(Debug, Clone)]
27pub struct WizardState {
28    /// Username for authentication
29    pub auth_username: Option<String>,
30    /// Password for authentication
31    pub auth_password: Option<String>,
32    /// Port for the web UI
33    pub port: u16,
34    /// Bind address (localhost or 0.0.0.0)
35    pub bind: String,
36    /// Image source preference: "prebuilt" or "build"
37    pub image_source: String,
38    /// Default bind mounts for persistence
39    pub mounts: Vec<String>,
40}
41
42impl WizardState {
43    /// Apply wizard state to a Config struct
44    pub fn apply_to_config(&self, config: &mut Config) {
45        if let Some(ref username) = self.auth_username {
46            config.auth_username = Some(username.clone());
47        }
48        if let Some(ref password) = self.auth_password {
49            config.auth_password = Some(password.clone());
50        }
51        config.opencode_web_port = self.port;
52        config.bind = self.bind.clone();
53        config.image_source = self.image_source.clone();
54        config.mounts = self.mounts.clone();
55    }
56}
57
58/// Handle Ctrl+C during wizard by restoring cursor and returning error
59fn handle_interrupt() -> anyhow::Error {
60    // Restore cursor in case it was hidden
61    let _ = Term::stdout().show_cursor();
62    anyhow!("Setup cancelled")
63}
64
65/// Prompt user to choose image source
66fn prompt_image_source(step: usize, total: usize) -> Result<String> {
67    println!(
68        "{}",
69        style(format!("Step {step}/{total}: Image Source"))
70            .cyan()
71            .bold()
72    );
73    println!();
74    println!("How would you like to get the Docker image?");
75    println!();
76    println!("  {} Pull prebuilt image (~2 minutes)", style("[1]").bold());
77    println!("      Download from GitHub Container Registry");
78    println!("      Fast, verified builds published automatically");
79    println!();
80    println!(
81        "  {} Build from source (30-60 minutes)",
82        style("[2]").bold()
83    );
84    println!("      Compile everything locally");
85    println!("      Full transparency, customizable Dockerfile");
86    println!();
87    println!(
88        "{}",
89        style("Build history: https://github.com/pRizz/opencode-cloud/actions").dim()
90    );
91    println!();
92
93    let options = vec!["Pull prebuilt image (recommended)", "Build from source"];
94
95    let selection = dialoguer::Select::new()
96        .with_prompt("Select image source")
97        .items(&options)
98        .default(0)
99        .interact()
100        .map_err(|_| handle_interrupt())?;
101
102    println!();
103
104    Ok(if selection == 0 { "prebuilt" } else { "build" }.to_string())
105}
106
107fn display_mounts_info(step: usize, total: usize, mounts: &[String]) -> Result<()> {
108    println!(
109        "{}",
110        style(format!("Step {step}/{total}: Data Persistence"))
111            .cyan()
112            .bold()
113    );
114    println!();
115    if mounts.is_empty() {
116        println!(
117            "{}",
118            style("No default host mounts are available.").yellow()
119        );
120        println!();
121        return Ok(());
122    }
123
124    println!("Persist opencode data, state, cache, workspace, and config using these mounts:");
125    println!();
126    for mount in mounts {
127        println!("  {}", style(mount).cyan());
128    }
129    println!();
130    println!(
131        "{}",
132        style("You can change these later with `occ mount add/remove` or by editing the config.",)
133            .dim()
134    );
135    println!();
136    Ok(())
137}
138
139fn prompt_mounts(step: usize, total: usize, mounts: &[String]) -> Result<Vec<String>> {
140    display_mounts_info(step, total, mounts)?;
141    if mounts.is_empty() {
142        return Ok(Vec::new());
143    }
144
145    let confirmed = Confirm::new()
146        .with_prompt("Use these host mounts for persistence?")
147        .default(true)
148        .interact()
149        .map_err(|_| handle_interrupt())?;
150    println!();
151
152    if confirmed {
153        Ok(mounts.to_vec())
154    } else {
155        Ok(Vec::new())
156    }
157}
158
159/// Run the interactive setup wizard
160///
161/// Guides the user through configuration, collecting values and returning
162/// a complete Config. Does NOT save - the caller is responsible for saving.
163///
164/// Creates PAM-based users in the container if it's running.
165/// Migrates old auth_username/auth_password to new users array.
166///
167/// # Arguments
168/// * `existing_config` - Optional existing config to show current values
169///
170/// # Returns
171/// * `Ok(Config)` - Completed configuration ready to save
172/// * `Err` - User cancelled or prechecks failed
173pub async fn run_wizard(existing_config: Option<&Config>) -> Result<Config> {
174    // 1. Prechecks
175    verify_tty()?;
176    verify_docker_available().await?;
177
178    // Connect to Docker for container operations
179    let client = DockerClient::new()?;
180    let is_container_running = container_is_running(&client, CONTAINER_NAME)
181        .await
182        .unwrap_or(false);
183
184    println!();
185    println!("{}", style("opencode-cloud Setup Wizard").cyan().bold());
186    println!("{}", style("=".repeat(30)).dim());
187    println!();
188
189    // 2. If existing config with users configured, show current summary and ask to reconfigure
190    if let Some(config) = existing_config {
191        let has_users = !config.users.is_empty();
192        let has_old_auth = config.has_required_auth();
193
194        if has_users || has_old_auth {
195            println!("{}", style("Current configuration:").bold());
196            if let Some(config_path) = opencode_cloud_core::config::paths::get_config_path() {
197                println!("  Config:   {}", style(config_path.display()).dim());
198            }
199            if has_users {
200                println!("  Users:    {}", config.users.join(", "));
201            } else if has_old_auth {
202                println!(
203                    "  Username: {} (legacy)",
204                    config.auth_username.as_deref().unwrap_or("-")
205                );
206                println!("  Password: ********");
207            }
208            println!("  Port:     {}", config.opencode_web_port);
209            println!("  Binding:  {}", config.bind);
210            println!("  Image:    {}", config.image_source);
211            if config.mounts.is_empty() {
212                println!("  Mounts:   {}", style("None").dim());
213            } else {
214                println!("  Mounts:");
215                for mount in &config.mounts {
216                    println!("    {}", style(mount).dim());
217                }
218            }
219            println!();
220            println!("{}", style("Full config:").bold());
221            for line in render_config_snapshot(config).lines() {
222                println!("  {}", style(line).dim());
223            }
224            println!();
225
226            let reconfigure = Confirm::new()
227                .with_prompt("Reconfigure?")
228                .default(false)
229                .interact()
230                .map_err(|_| handle_interrupt())?;
231
232            if !reconfigure {
233                return Err(anyhow!("Setup cancelled"));
234            }
235            println!();
236        }
237    }
238
239    // 3. Quick setup offer
240    let quick = Confirm::new()
241        .with_prompt("Use defaults for everything except credentials?")
242        .default(false)
243        .interact()
244        .map_err(|_| handle_interrupt())?;
245
246    println!();
247
248    // 4. Collect values
249    let total_steps = if quick { 3 } else { 5 };
250
251    let (username, password) = prompt_auth(1, total_steps)?;
252    let image_source = prompt_image_source(2, total_steps)?;
253
254    let (port, bind) = if quick {
255        (3000, "localhost".to_string())
256    } else {
257        let port = prompt_port(3, total_steps, 3000)?;
258        let bind = prompt_hostname(4, total_steps, "localhost")?;
259        (port, bind)
260    };
261
262    let default_mounts = default_mounts();
263    let mounts = if quick {
264        display_mounts_info(3, total_steps, &default_mounts)?;
265        default_mounts
266    } else {
267        prompt_mounts(5, total_steps, &default_mounts)?
268    };
269
270    let state = WizardState {
271        auth_username: Some(username.clone()),
272        auth_password: Some(password.clone()),
273        port,
274        bind,
275        image_source,
276        mounts,
277    };
278
279    // 5. Summary
280    println!();
281    display_summary(&state);
282    println!();
283
284    // 6. Confirm save
285    let save = Confirm::new()
286        .with_prompt("Save this configuration?")
287        .default(true)
288        .interact()
289        .map_err(|_| handle_interrupt())?;
290
291    if !save {
292        return Err(anyhow!("Setup cancelled"));
293    }
294
295    // 7. Create user in container if running
296    if is_container_running {
297        println!();
298        println!("{}", style("Creating user in container...").cyan());
299        auth::create_container_user(&client, &username, &password).await?;
300    } else {
301        println!();
302        println!(
303            "{}",
304            style("Note: User will be created when container starts.").dim()
305        );
306    }
307
308    // 8. Build and return config
309    let mut config = existing_config.cloned().unwrap_or_default();
310    state.apply_to_config(&mut config);
311
312    // Update config.users array (PAM-based auth tracking)
313    if !config.users.contains(&username) {
314        config.users.push(username);
315    }
316
317    // Migrate old auth_username/auth_password if present
318    if let Some(ref old_username) = config.auth_username {
319        if !old_username.is_empty() && !config.users.contains(old_username) {
320            println!(
321                "{}",
322                style(format!(
323                    "Migrating existing user '{old_username}' to PAM-based authentication..."
324                ))
325                .dim()
326            );
327            config.users.push(old_username.clone());
328        }
329    }
330
331    // Clear legacy auth fields (keep them empty for schema compatibility)
332    config.auth_username = Some(String::new());
333    config.auth_password = Some(String::new());
334
335    Ok(config)
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn test_wizard_state_apply_to_config() {
344        let state = WizardState {
345            auth_username: Some("testuser".to_string()),
346            auth_password: Some("testpass".to_string()),
347            port: 8080,
348            bind: "0.0.0.0".to_string(),
349            image_source: "prebuilt".to_string(),
350            mounts: default_mounts(),
351        };
352
353        let mut config = Config::default();
354        state.apply_to_config(&mut config);
355
356        assert_eq!(config.auth_username, Some("testuser".to_string()));
357        assert_eq!(config.auth_password, Some("testpass".to_string()));
358        assert_eq!(config.opencode_web_port, 8080);
359        assert_eq!(config.bind, "0.0.0.0");
360        assert_eq!(config.image_source, "prebuilt");
361    }
362
363    #[test]
364    fn test_wizard_state_preserves_other_config_fields() {
365        let state = WizardState {
366            auth_username: Some("admin".to_string()),
367            auth_password: Some("secret".to_string()),
368            port: 3000,
369            bind: "localhost".to_string(),
370            image_source: "build".to_string(),
371            mounts: default_mounts(),
372        };
373
374        let mut config = Config {
375            auto_restart: false,
376            restart_retries: 10,
377            ..Config::default()
378        };
379        state.apply_to_config(&mut config);
380
381        // Should preserve existing fields
382        assert!(!config.auto_restart);
383        assert_eq!(config.restart_retries, 10);
384
385        // Should update wizard fields
386        assert_eq!(config.auth_username, Some("admin".to_string()));
387        assert_eq!(config.image_source, "build");
388    }
389}