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!("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
180pub async fn run_wizard(existing_config: Option<&Config>) -> Result<Config> {
192 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 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 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 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 println!();
291 display_summary(&state);
292 println!();
293
294 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 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 assert!(!config.auto_restart);
353 assert_eq!(config.restart_retries, 10);
354
355 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}