opencode_cloud/wizard/
mod.rs1mod 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::Config;
17use opencode_cloud_core::docker::{CONTAINER_NAME, DockerClient, container_is_running};
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}
37
38impl WizardState {
39 pub fn apply_to_config(&self, config: &mut Config) {
41 if let Some(ref username) = self.auth_username {
42 config.auth_username = Some(username.clone());
43 }
44 if let Some(ref password) = self.auth_password {
45 config.auth_password = Some(password.clone());
46 }
47 config.opencode_web_port = self.port;
48 config.bind = self.bind.clone();
49 config.image_source = self.image_source.clone();
50 }
51}
52
53fn handle_interrupt() -> anyhow::Error {
55 let _ = Term::stdout().show_cursor();
57 anyhow!("Setup cancelled")
58}
59
60fn prompt_image_source(step: usize, total: usize) -> Result<String> {
62 println!(
63 "{}",
64 style(format!("Step {step}/{total}: Image Source"))
65 .cyan()
66 .bold()
67 );
68 println!();
69 println!("How would you like to get the Docker image?");
70 println!();
71 println!(" {} Pull prebuilt image (~2 minutes)", style("[1]").bold());
72 println!(" Download from GitHub Container Registry");
73 println!(" Fast, verified builds published automatically");
74 println!();
75 println!(
76 " {} Build from source (30-60 minutes)",
77 style("[2]").bold()
78 );
79 println!(" Compile everything locally");
80 println!(" Full transparency, customizable Dockerfile");
81 println!();
82 println!(
83 "{}",
84 style("Build history: https://github.com/pRizz/opencode-cloud/actions").dim()
85 );
86 println!();
87
88 let options = vec!["Pull prebuilt image (recommended)", "Build from source"];
89
90 let selection = dialoguer::Select::new()
91 .with_prompt("Select image source")
92 .items(&options)
93 .default(0)
94 .interact()
95 .map_err(|_| handle_interrupt())?;
96
97 println!();
98
99 Ok(if selection == 0 { "prebuilt" } else { "build" }.to_string())
100}
101
102pub async fn run_wizard(existing_config: Option<&Config>) -> Result<Config> {
117 verify_tty()?;
119 verify_docker_available().await?;
120
121 let client = DockerClient::new()?;
123 let is_container_running = container_is_running(&client, CONTAINER_NAME)
124 .await
125 .unwrap_or(false);
126
127 println!();
128 println!("{}", style("opencode-cloud Setup Wizard").cyan().bold());
129 println!("{}", style("=".repeat(30)).dim());
130 println!();
131
132 if let Some(config) = existing_config {
134 let has_users = !config.users.is_empty();
135 let has_old_auth = config.has_required_auth();
136
137 if has_users || has_old_auth {
138 println!("{}", style("Current configuration:").bold());
139 if has_users {
140 println!(" Users: {}", config.users.join(", "));
141 } else if has_old_auth {
142 println!(
143 " Username: {} (legacy)",
144 config.auth_username.as_deref().unwrap_or("-")
145 );
146 println!(" Password: ********");
147 }
148 println!(" Port: {}", config.opencode_web_port);
149 println!(" Binding: {}", config.bind);
150 println!();
151
152 let reconfigure = Confirm::new()
153 .with_prompt("Reconfigure?")
154 .default(false)
155 .interact()
156 .map_err(|_| handle_interrupt())?;
157
158 if !reconfigure {
159 return Err(anyhow!("Setup cancelled"));
160 }
161 println!();
162 }
163 }
164
165 let quick = Confirm::new()
167 .with_prompt("Use defaults for everything except credentials?")
168 .default(false)
169 .interact()
170 .map_err(|_| handle_interrupt())?;
171
172 println!();
173
174 let total_steps = if quick { 2 } else { 4 };
176
177 let (username, password) = prompt_auth(1, total_steps)?;
178 let image_source = prompt_image_source(2, total_steps)?;
179
180 let (port, bind) = if quick {
181 (3000, "localhost".to_string())
182 } else {
183 let port = prompt_port(3, total_steps, 3000)?;
184 let bind = prompt_hostname(4, total_steps, "localhost")?;
185 (port, bind)
186 };
187
188 let state = WizardState {
189 auth_username: Some(username.clone()),
190 auth_password: Some(password.clone()),
191 port,
192 bind,
193 image_source,
194 };
195
196 println!();
198 display_summary(&state);
199 println!();
200
201 let save = Confirm::new()
203 .with_prompt("Save this configuration?")
204 .default(true)
205 .interact()
206 .map_err(|_| handle_interrupt())?;
207
208 if !save {
209 return Err(anyhow!("Setup cancelled"));
210 }
211
212 if is_container_running {
214 println!();
215 println!("{}", style("Creating user in container...").cyan());
216 auth::create_container_user(&client, &username, &password).await?;
217 } else {
218 println!();
219 println!(
220 "{}",
221 style("Note: User will be created when container starts.").dim()
222 );
223 }
224
225 let mut config = existing_config.cloned().unwrap_or_default();
227 state.apply_to_config(&mut config);
228
229 if !config.users.contains(&username) {
231 config.users.push(username);
232 }
233
234 if let Some(ref old_username) = config.auth_username {
236 if !old_username.is_empty() && !config.users.contains(old_username) {
237 println!(
238 "{}",
239 style(format!(
240 "Migrating existing user '{old_username}' to PAM-based authentication..."
241 ))
242 .dim()
243 );
244 config.users.push(old_username.clone());
245 }
246 }
247
248 config.auth_username = Some(String::new());
250 config.auth_password = Some(String::new());
251
252 Ok(config)
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 #[test]
260 fn test_wizard_state_apply_to_config() {
261 let state = WizardState {
262 auth_username: Some("testuser".to_string()),
263 auth_password: Some("testpass".to_string()),
264 port: 8080,
265 bind: "0.0.0.0".to_string(),
266 image_source: "prebuilt".to_string(),
267 };
268
269 let mut config = Config::default();
270 state.apply_to_config(&mut config);
271
272 assert_eq!(config.auth_username, Some("testuser".to_string()));
273 assert_eq!(config.auth_password, Some("testpass".to_string()));
274 assert_eq!(config.opencode_web_port, 8080);
275 assert_eq!(config.bind, "0.0.0.0");
276 assert_eq!(config.image_source, "prebuilt");
277 }
278
279 #[test]
280 fn test_wizard_state_preserves_other_config_fields() {
281 let state = WizardState {
282 auth_username: Some("admin".to_string()),
283 auth_password: Some("secret".to_string()),
284 port: 3000,
285 bind: "localhost".to_string(),
286 image_source: "build".to_string(),
287 };
288
289 let mut config = Config {
290 auto_restart: false,
291 restart_retries: 10,
292 ..Config::default()
293 };
294 state.apply_to_config(&mut config);
295
296 assert!(!config.auto_restart);
298 assert_eq!(config.restart_retries, 10);
299
300 assert_eq!(config.auth_username, Some("admin".to_string()));
302 assert_eq!(config.image_source, "build");
303 }
304}