mod config_view;
mod network;
mod prechecks;
mod summary;
pub use prechecks::{verify_docker_available, verify_tty};
use anyhow::{Result, anyhow};
use console::{Term, style};
use dialoguer::Confirm;
use opencode_cloud_core::{Config, config::default_mounts};
use config_view::render_config_snapshot;
use network::{prompt_hostname, prompt_port};
use summary::display_summary;
#[derive(Debug, Clone)]
pub struct WizardState {
pub port: u16,
pub bind: String,
pub image_source: String,
pub mounts: Vec<String>,
}
impl WizardState {
pub fn apply_to_config(&self, config: &mut Config) {
config.opencode_web_port = self.port;
config.bind = self.bind.clone();
config.image_source = self.image_source.clone();
config.mounts = self.mounts.clone();
}
}
fn handle_interrupt() -> anyhow::Error {
let _ = Term::stdout().show_cursor();
anyhow!("Setup cancelled")
}
fn display_auth_bootstrap_info(step: usize, total: usize) -> Result<()> {
println!(
"{}",
style(format!("Step {step}/{total}: Authentication Onboarding"))
.cyan()
.bold()
);
println!();
println!(
"First-time sign-in now uses an Initial One-Time Password (IOTP) and passkey enrollment."
);
println!("This wizard does not create usernames/passwords.");
println!();
println!(
"{}",
style("After setup, start the service and complete authentication in the web login page.")
.dim()
);
println!("{}", style("Admin fallback: occ user add <username>").dim());
println!();
let proceed = Confirm::new()
.with_prompt("Continue with IOTP-first onboarding?")
.default(true)
.interact()
.map_err(|_| handle_interrupt())?;
if !proceed {
return Err(anyhow!("Setup cancelled"));
}
println!();
Ok(())
}
fn prompt_image_source(step: usize, total: usize) -> Result<String> {
println!(
"{}",
style(format!("Step {step}/{total}: Image Source"))
.cyan()
.bold()
);
println!();
println!("How would you like to get the Docker image?");
println!();
println!(" {} Pull prebuilt image (~2 minutes)", style("[1]").bold());
println!(" Download from GitHub Container Registry");
println!(" Fast, verified builds published automatically");
println!();
println!(
" {} Build from source (30-60 minutes)",
style("[2]").bold()
);
println!(" Compile everything locally");
println!(" Full transparency, customizable Dockerfile");
println!();
println!(
"{}",
style("Build history: https://github.com/pRizz/opencode-cloud/actions").dim()
);
println!();
let options = vec!["Pull prebuilt image (recommended)", "Build from source"];
let selection = dialoguer::Select::new()
.with_prompt("Select image source")
.items(&options)
.default(0)
.interact()
.map_err(|_| handle_interrupt())?;
println!();
Ok(if selection == 0 { "prebuilt" } else { "build" }.to_string())
}
fn display_mounts_info(step: usize, total: usize, mounts: &[String]) -> Result<()> {
println!(
"{}",
style(format!("Step {step}/{total}: Data Persistence"))
.cyan()
.bold()
);
println!();
if mounts.is_empty() {
println!(
"{}",
style("No default host mounts are available.").yellow()
);
println!();
return Ok(());
}
println!(
"Persist opencode data, state, cache, workspace, config, and SSH keys using these mounts:"
);
println!();
for mount in mounts {
println!(" {}", style(mount).cyan());
}
println!();
println!(
"{}",
style("You can change these later with `occ mount add/remove` or by editing the config.",)
.dim()
);
println!();
Ok(())
}
fn prompt_mounts(step: usize, total: usize, mounts: &[String]) -> Result<Vec<String>> {
display_mounts_info(step, total, mounts)?;
if mounts.is_empty() {
return Ok(Vec::new());
}
let confirmed = Confirm::new()
.with_prompt("Use these host mounts for persistence?")
.default(true)
.interact()
.map_err(|_| handle_interrupt())?;
println!();
if confirmed {
Ok(mounts.to_vec())
} else {
Ok(Vec::new())
}
}
pub async fn run_wizard(existing_config: Option<&Config>) -> Result<Config> {
verify_tty()?;
verify_docker_available().await?;
println!();
println!("{}", style("opencode-cloud Setup Wizard").cyan().bold());
println!("{}", style("=".repeat(30)).dim());
println!();
if let Some(config) = existing_config {
let has_users = !config.users.is_empty();
let has_old_auth = config.has_required_auth();
if has_users || has_old_auth {
println!("{}", style("Current configuration:").bold());
if let Some(config_path) = opencode_cloud_core::config::paths::get_config_path() {
println!(" Config: {}", style(config_path.display()).dim());
}
if has_users {
println!(" Users: {}", config.users.join(", "));
} else if has_old_auth {
println!(
" Username: {} (legacy)",
config.auth_username.as_deref().unwrap_or("-")
);
println!(" Password: ********");
}
println!(" Port: {}", config.opencode_web_port);
println!(" Binding: {}", config.bind);
println!(" Image: {}", config.image_source);
if config.mounts.is_empty() {
println!(" Mounts: {}", style("None").dim());
} else {
println!(" Mounts:");
for mount in &config.mounts {
println!(" {}", style(mount).dim());
}
}
println!();
println!("{}", style("Full config:").bold());
for line in render_config_snapshot(config).lines() {
println!(" {}", style(line).dim());
}
println!();
let reconfigure = Confirm::new()
.with_prompt("Reconfigure?")
.default(false)
.interact()
.map_err(|_| handle_interrupt())?;
if !reconfigure {
return Err(anyhow!("Setup cancelled"));
}
println!();
}
}
let quick = Confirm::new()
.with_prompt("Use defaults for network and persistence settings?")
.default(false)
.interact()
.map_err(|_| handle_interrupt())?;
println!();
let total_steps = if quick { 3 } else { 5 };
display_auth_bootstrap_info(1, total_steps)?;
let image_source = prompt_image_source(2, total_steps)?;
let (port, bind) = if quick {
(3000, "localhost".to_string())
} else {
let port = prompt_port(3, total_steps, 3000)?;
let bind = prompt_hostname(4, total_steps, "localhost")?;
(port, bind)
};
let default_mounts = default_mounts();
let mounts = if quick {
display_mounts_info(3, total_steps, &default_mounts)?;
default_mounts
} else {
prompt_mounts(5, total_steps, &default_mounts)?
};
let state = WizardState {
port,
bind,
image_source,
mounts,
};
println!();
display_summary(&state);
println!();
let save = Confirm::new()
.with_prompt("Save this configuration?")
.default(true)
.interact()
.map_err(|_| handle_interrupt())?;
if !save {
return Err(anyhow!("Setup cancelled"));
}
let mut config = existing_config.cloned().unwrap_or_default();
state.apply_to_config(&mut config);
Ok(config)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_wizard_state_apply_to_config() {
let state = WizardState {
port: 8080,
bind: "0.0.0.0".to_string(),
image_source: "prebuilt".to_string(),
mounts: default_mounts(),
};
let mut config = Config::default();
state.apply_to_config(&mut config);
assert_eq!(config.opencode_web_port, 8080);
assert_eq!(config.bind, "0.0.0.0");
assert_eq!(config.image_source, "prebuilt");
}
#[test]
fn test_wizard_state_preserves_other_config_fields() {
let state = WizardState {
port: 3000,
bind: "localhost".to_string(),
image_source: "build".to_string(),
mounts: default_mounts(),
};
let mut config = Config {
auto_restart: false,
restart_retries: 10,
auth_username: Some("legacy-user".to_string()),
auth_password: Some("legacy-password".to_string()),
..Config::default()
};
state.apply_to_config(&mut config);
assert!(!config.auto_restart);
assert_eq!(config.restart_retries, 10);
assert_eq!(config.auth_username, Some("legacy-user".to_string()));
assert_eq!(config.auth_password, Some("legacy-password".to_string()));
assert_eq!(config.image_source, "build");
}
}