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