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