1mod 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#[derive(Debug, Clone)]
23pub struct WizardState {
24 pub port: u16,
26 pub bind: String,
28 pub image_source: String,
30 pub mounts: Vec<String>,
32}
33
34impl WizardState {
35 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
44fn handle_interrupt() -> anyhow::Error {
46 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
86fn 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
182pub async fn run_wizard(existing_config: Option<&Config>) -> Result<Config> {
194 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 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 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 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 println!();
293 display_summary(&state);
294 println!();
295
296 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 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 assert!(!config.auto_restart);
355 assert_eq!(config.restart_retries, 10);
356
357 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}