1mod auth;
6mod config_view;
7mod network;
8mod prechecks;
9mod summary;
10
11pub use auth::create_container_user;
12pub use prechecks::{verify_docker_available, verify_tty};
13
14use anyhow::{Result, anyhow};
15use console::{Term, style};
16use dialoguer::Confirm;
17use opencode_cloud_core::docker::{CONTAINER_NAME, DockerClient, container_is_running};
18use opencode_cloud_core::{Config, config::default_mounts};
19
20use auth::prompt_auth;
21use config_view::render_config_snapshot;
22use network::{prompt_hostname, prompt_port};
23use summary::display_summary;
24
25#[derive(Debug, Clone)]
27pub struct WizardState {
28 pub auth_username: Option<String>,
30 pub auth_password: Option<String>,
32 pub port: u16,
34 pub bind: String,
36 pub image_source: String,
38 pub mounts: Vec<String>,
40}
41
42impl WizardState {
43 pub fn apply_to_config(&self, config: &mut Config) {
45 if let Some(ref username) = self.auth_username {
46 config.auth_username = Some(username.clone());
47 }
48 if let Some(ref password) = self.auth_password {
49 config.auth_password = Some(password.clone());
50 }
51 config.opencode_web_port = self.port;
52 config.bind = self.bind.clone();
53 config.image_source = self.image_source.clone();
54 config.mounts = self.mounts.clone();
55 }
56}
57
58fn handle_interrupt() -> anyhow::Error {
60 let _ = Term::stdout().show_cursor();
62 anyhow!("Setup cancelled")
63}
64
65fn prompt_image_source(step: usize, total: usize) -> Result<String> {
67 println!(
68 "{}",
69 style(format!("Step {step}/{total}: Image Source"))
70 .cyan()
71 .bold()
72 );
73 println!();
74 println!("How would you like to get the Docker image?");
75 println!();
76 println!(" {} Pull prebuilt image (~2 minutes)", style("[1]").bold());
77 println!(" Download from GitHub Container Registry");
78 println!(" Fast, verified builds published automatically");
79 println!();
80 println!(
81 " {} Build from source (30-60 minutes)",
82 style("[2]").bold()
83 );
84 println!(" Compile everything locally");
85 println!(" Full transparency, customizable Dockerfile");
86 println!();
87 println!(
88 "{}",
89 style("Build history: https://github.com/pRizz/opencode-cloud/actions").dim()
90 );
91 println!();
92
93 let options = vec!["Pull prebuilt image (recommended)", "Build from source"];
94
95 let selection = dialoguer::Select::new()
96 .with_prompt("Select image source")
97 .items(&options)
98 .default(0)
99 .interact()
100 .map_err(|_| handle_interrupt())?;
101
102 println!();
103
104 Ok(if selection == 0 { "prebuilt" } else { "build" }.to_string())
105}
106
107fn display_mounts_info(step: usize, total: usize, mounts: &[String]) -> Result<()> {
108 println!(
109 "{}",
110 style(format!("Step {step}/{total}: Data Persistence"))
111 .cyan()
112 .bold()
113 );
114 println!();
115 if mounts.is_empty() {
116 println!(
117 "{}",
118 style("No default host mounts are available.").yellow()
119 );
120 println!();
121 return Ok(());
122 }
123
124 println!("Persist opencode data, state, cache, workspace, and config using these mounts:");
125 println!();
126 for mount in mounts {
127 println!(" {}", style(mount).cyan());
128 }
129 println!();
130 println!(
131 "{}",
132 style("You can change these later with `occ mount add/remove` or by editing the config.",)
133 .dim()
134 );
135 println!();
136 Ok(())
137}
138
139fn prompt_mounts(step: usize, total: usize, mounts: &[String]) -> Result<Vec<String>> {
140 display_mounts_info(step, total, mounts)?;
141 if mounts.is_empty() {
142 return Ok(Vec::new());
143 }
144
145 let confirmed = Confirm::new()
146 .with_prompt("Use these host mounts for persistence?")
147 .default(true)
148 .interact()
149 .map_err(|_| handle_interrupt())?;
150 println!();
151
152 if confirmed {
153 Ok(mounts.to_vec())
154 } else {
155 Ok(Vec::new())
156 }
157}
158
159pub async fn run_wizard(existing_config: Option<&Config>) -> Result<Config> {
174 verify_tty()?;
176 verify_docker_available().await?;
177
178 let client = DockerClient::new()?;
180 let is_container_running = container_is_running(&client, CONTAINER_NAME)
181 .await
182 .unwrap_or(false);
183
184 println!();
185 println!("{}", style("opencode-cloud Setup Wizard").cyan().bold());
186 println!("{}", style("=".repeat(30)).dim());
187 println!();
188
189 if let Some(config) = existing_config {
191 let has_users = !config.users.is_empty();
192 let has_old_auth = config.has_required_auth();
193
194 if has_users || has_old_auth {
195 println!("{}", style("Current configuration:").bold());
196 if let Some(config_path) = opencode_cloud_core::config::paths::get_config_path() {
197 println!(" Config: {}", style(config_path.display()).dim());
198 }
199 if has_users {
200 println!(" Users: {}", config.users.join(", "));
201 } else if has_old_auth {
202 println!(
203 " Username: {} (legacy)",
204 config.auth_username.as_deref().unwrap_or("-")
205 );
206 println!(" Password: ********");
207 }
208 println!(" Port: {}", config.opencode_web_port);
209 println!(" Binding: {}", config.bind);
210 println!(" Image: {}", config.image_source);
211 if config.mounts.is_empty() {
212 println!(" Mounts: {}", style("None").dim());
213 } else {
214 println!(" Mounts:");
215 for mount in &config.mounts {
216 println!(" {}", style(mount).dim());
217 }
218 }
219 println!();
220 println!("{}", style("Full config:").bold());
221 for line in render_config_snapshot(config).lines() {
222 println!(" {}", style(line).dim());
223 }
224 println!();
225
226 let reconfigure = Confirm::new()
227 .with_prompt("Reconfigure?")
228 .default(false)
229 .interact()
230 .map_err(|_| handle_interrupt())?;
231
232 if !reconfigure {
233 return Err(anyhow!("Setup cancelled"));
234 }
235 println!();
236 }
237 }
238
239 let quick = Confirm::new()
241 .with_prompt("Use defaults for everything except credentials?")
242 .default(false)
243 .interact()
244 .map_err(|_| handle_interrupt())?;
245
246 println!();
247
248 let total_steps = if quick { 3 } else { 5 };
250
251 let (username, password) = prompt_auth(1, total_steps)?;
252 let image_source = prompt_image_source(2, total_steps)?;
253
254 let (port, bind) = if quick {
255 (3000, "localhost".to_string())
256 } else {
257 let port = prompt_port(3, total_steps, 3000)?;
258 let bind = prompt_hostname(4, total_steps, "localhost")?;
259 (port, bind)
260 };
261
262 let default_mounts = default_mounts();
263 let mounts = if quick {
264 display_mounts_info(3, total_steps, &default_mounts)?;
265 default_mounts
266 } else {
267 prompt_mounts(5, total_steps, &default_mounts)?
268 };
269
270 let state = WizardState {
271 auth_username: Some(username.clone()),
272 auth_password: Some(password.clone()),
273 port,
274 bind,
275 image_source,
276 mounts,
277 };
278
279 println!();
281 display_summary(&state);
282 println!();
283
284 let save = Confirm::new()
286 .with_prompt("Save this configuration?")
287 .default(true)
288 .interact()
289 .map_err(|_| handle_interrupt())?;
290
291 if !save {
292 return Err(anyhow!("Setup cancelled"));
293 }
294
295 if is_container_running {
297 println!();
298 println!("{}", style("Creating user in container...").cyan());
299 auth::create_container_user(&client, &username, &password).await?;
300 } else {
301 println!();
302 println!(
303 "{}",
304 style("Note: User will be created when container starts.").dim()
305 );
306 }
307
308 let mut config = existing_config.cloned().unwrap_or_default();
310 state.apply_to_config(&mut config);
311
312 if !config.users.contains(&username) {
314 config.users.push(username);
315 }
316
317 if let Some(ref old_username) = config.auth_username {
319 if !old_username.is_empty() && !config.users.contains(old_username) {
320 println!(
321 "{}",
322 style(format!(
323 "Migrating existing user '{old_username}' to PAM-based authentication..."
324 ))
325 .dim()
326 );
327 config.users.push(old_username.clone());
328 }
329 }
330
331 config.auth_username = Some(String::new());
333 config.auth_password = Some(String::new());
334
335 Ok(config)
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 #[test]
343 fn test_wizard_state_apply_to_config() {
344 let state = WizardState {
345 auth_username: Some("testuser".to_string()),
346 auth_password: Some("testpass".to_string()),
347 port: 8080,
348 bind: "0.0.0.0".to_string(),
349 image_source: "prebuilt".to_string(),
350 mounts: default_mounts(),
351 };
352
353 let mut config = Config::default();
354 state.apply_to_config(&mut config);
355
356 assert_eq!(config.auth_username, Some("testuser".to_string()));
357 assert_eq!(config.auth_password, Some("testpass".to_string()));
358 assert_eq!(config.opencode_web_port, 8080);
359 assert_eq!(config.bind, "0.0.0.0");
360 assert_eq!(config.image_source, "prebuilt");
361 }
362
363 #[test]
364 fn test_wizard_state_preserves_other_config_fields() {
365 let state = WizardState {
366 auth_username: Some("admin".to_string()),
367 auth_password: Some("secret".to_string()),
368 port: 3000,
369 bind: "localhost".to_string(),
370 image_source: "build".to_string(),
371 mounts: default_mounts(),
372 };
373
374 let mut config = Config {
375 auto_restart: false,
376 restart_retries: 10,
377 ..Config::default()
378 };
379 state.apply_to_config(&mut config);
380
381 assert!(!config.auto_restart);
383 assert_eq!(config.restart_retries, 10);
384
385 assert_eq!(config.auth_username, Some("admin".to_string()));
387 assert_eq!(config.image_source, "build");
388 }
389}