1mod 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#[derive(Debug, Clone)]
25pub struct WizardState {
26 pub auth_username: Option<String>,
28 pub auth_password: Option<String>,
30 pub port: u16,
32 pub bind: String,
34 pub image_source: String,
36 pub mounts: Vec<String>,
38}
39
40impl WizardState {
41 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
56fn handle_interrupt() -> anyhow::Error {
58 let _ = Term::stdout().show_cursor();
60 anyhow!("Setup cancelled")
61}
62
63fn 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
157pub async fn run_wizard(existing_config: Option<&Config>) -> Result<Config> {
172 verify_tty()?;
174 verify_docker_available().await?;
175
176 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 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 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 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 println!();
262 display_summary(&state);
263 println!();
264
265 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 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 let mut config = existing_config.cloned().unwrap_or_default();
291 state.apply_to_config(&mut config);
292
293 if !config.users.contains(&username) {
295 config.users.push(username);
296 }
297
298 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 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 assert!(!config.auto_restart);
364 assert_eq!(config.restart_retries, 10);
365
366 assert_eq!(config.auth_username, Some("admin".to_string()));
368 assert_eq!(config.image_source, "build");
369 }
370}