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!("Persist opencode data, state, cache, workspace, and config using these mounts:");
146    println!();
147    for mount in mounts {
148        println!("  {}", style(mount).cyan());
149    }
150    println!();
151    println!(
152        "{}",
153        style("You can change these later with `occ mount add/remove` or by editing the config.",)
154            .dim()
155    );
156    println!();
157    Ok(())
158}
159
160fn prompt_mounts(step: usize, total: usize, mounts: &[String]) -> Result<Vec<String>> {
161    display_mounts_info(step, total, mounts)?;
162    if mounts.is_empty() {
163        return Ok(Vec::new());
164    }
165
166    let confirmed = Confirm::new()
167        .with_prompt("Use these host mounts for persistence?")
168        .default(true)
169        .interact()
170        .map_err(|_| handle_interrupt())?;
171    println!();
172
173    if confirmed {
174        Ok(mounts.to_vec())
175    } else {
176        Ok(Vec::new())
177    }
178}
179
180/// Run the interactive setup wizard
181///
182/// Guides the user through configuration, collecting values and returning
183/// a complete Config. Does NOT save - the caller is responsible for saving.
184///
185/// # Arguments
186/// * `existing_config` - Optional existing config to show current values
187///
188/// # Returns
189/// * `Ok(Config)` - Completed configuration ready to save
190/// * `Err` - User cancelled or prechecks failed
191pub async fn run_wizard(existing_config: Option<&Config>) -> Result<Config> {
192    // 1. Prechecks
193    verify_tty()?;
194    verify_docker_available().await?;
195
196    println!();
197    println!("{}", style("opencode-cloud Setup Wizard").cyan().bold());
198    println!("{}", style("=".repeat(30)).dim());
199    println!();
200
201    // 2. If existing config with users configured, show current summary and ask to reconfigure
202    if let Some(config) = existing_config {
203        let has_users = !config.users.is_empty();
204        let has_old_auth = config.has_required_auth();
205
206        if has_users || has_old_auth {
207            println!("{}", style("Current configuration:").bold());
208            if let Some(config_path) = opencode_cloud_core::config::paths::get_config_path() {
209                println!("  Config:   {}", style(config_path.display()).dim());
210            }
211            if has_users {
212                println!("  Users:    {}", config.users.join(", "));
213            } else if has_old_auth {
214                println!(
215                    "  Username: {} (legacy)",
216                    config.auth_username.as_deref().unwrap_or("-")
217                );
218                println!("  Password: ********");
219            }
220            println!("  Port:     {}", config.opencode_web_port);
221            println!("  Binding:  {}", config.bind);
222            println!("  Image:    {}", config.image_source);
223            if config.mounts.is_empty() {
224                println!("  Mounts:   {}", style("None").dim());
225            } else {
226                println!("  Mounts:");
227                for mount in &config.mounts {
228                    println!("    {}", style(mount).dim());
229                }
230            }
231            println!();
232            println!("{}", style("Full config:").bold());
233            for line in render_config_snapshot(config).lines() {
234                println!("  {}", style(line).dim());
235            }
236            println!();
237
238            let reconfigure = Confirm::new()
239                .with_prompt("Reconfigure?")
240                .default(false)
241                .interact()
242                .map_err(|_| handle_interrupt())?;
243
244            if !reconfigure {
245                return Err(anyhow!("Setup cancelled"));
246            }
247            println!();
248        }
249    }
250
251    // 3. Quick setup offer
252    let quick = Confirm::new()
253        .with_prompt("Use defaults for network and persistence settings?")
254        .default(false)
255        .interact()
256        .map_err(|_| handle_interrupt())?;
257
258    println!();
259
260    // 4. Collect values
261    let total_steps = if quick { 3 } else { 5 };
262
263    display_auth_bootstrap_info(1, total_steps)?;
264    let image_source = prompt_image_source(2, total_steps)?;
265
266    let (port, bind) = if quick {
267        (3000, "localhost".to_string())
268    } else {
269        let port = prompt_port(3, total_steps, 3000)?;
270        let bind = prompt_hostname(4, total_steps, "localhost")?;
271        (port, bind)
272    };
273
274    let default_mounts = default_mounts();
275    let mounts = if quick {
276        display_mounts_info(3, total_steps, &default_mounts)?;
277        default_mounts
278    } else {
279        prompt_mounts(5, total_steps, &default_mounts)?
280    };
281
282    let state = WizardState {
283        port,
284        bind,
285        image_source,
286        mounts,
287    };
288
289    // 5. Summary
290    println!();
291    display_summary(&state);
292    println!();
293
294    // 6. Confirm save
295    let save = Confirm::new()
296        .with_prompt("Save this configuration?")
297        .default(true)
298        .interact()
299        .map_err(|_| handle_interrupt())?;
300
301    if !save {
302        return Err(anyhow!("Setup cancelled"));
303    }
304
305    // 7. Build and return config
306    let mut config = existing_config.cloned().unwrap_or_default();
307    state.apply_to_config(&mut config);
308
309    Ok(config)
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn test_wizard_state_apply_to_config() {
318        let state = WizardState {
319            port: 8080,
320            bind: "0.0.0.0".to_string(),
321            image_source: "prebuilt".to_string(),
322            mounts: default_mounts(),
323        };
324
325        let mut config = Config::default();
326        state.apply_to_config(&mut config);
327
328        assert_eq!(config.opencode_web_port, 8080);
329        assert_eq!(config.bind, "0.0.0.0");
330        assert_eq!(config.image_source, "prebuilt");
331    }
332
333    #[test]
334    fn test_wizard_state_preserves_other_config_fields() {
335        let state = WizardState {
336            port: 3000,
337            bind: "localhost".to_string(),
338            image_source: "build".to_string(),
339            mounts: default_mounts(),
340        };
341
342        let mut config = Config {
343            auto_restart: false,
344            restart_retries: 10,
345            auth_username: Some("legacy-user".to_string()),
346            auth_password: Some("legacy-password".to_string()),
347            ..Config::default()
348        };
349        state.apply_to_config(&mut config);
350
351        // Should preserve existing fields
352        assert!(!config.auto_restart);
353        assert_eq!(config.restart_retries, 10);
354
355        // Should update wizard fields
356        assert_eq!(config.auth_username, Some("legacy-user".to_string()));
357        assert_eq!(config.auth_password, Some("legacy-password".to_string()));
358        assert_eq!(config.image_source, "build");
359    }
360}