Skip to main content

opencode_cloud/
lib.rs

1//! opencode-cloud CLI - Manage your opencode cloud service
2//!
3//! This module contains the shared CLI implementation used by all binaries.
4
5mod commands;
6mod output;
7pub mod wizard;
8
9use anyhow::Result;
10use clap::{Parser, Subcommand};
11use console::style;
12use opencode_cloud_core::{
13    DockerClient, InstanceLock, SingletonError, config, get_version, load_config, load_hosts,
14    save_config,
15};
16
17/// Manage your opencode cloud service
18#[derive(Parser)]
19#[command(name = "opencode-cloud")]
20#[command(version = env!("CARGO_PKG_VERSION"))]
21#[command(about = "Manage your opencode cloud service", long_about = None)]
22#[command(after_help = get_banner())]
23struct Cli {
24    #[command(subcommand)]
25    command: Option<Commands>,
26
27    /// Increase verbosity level
28    #[arg(short, long, global = true, action = clap::ArgAction::Count)]
29    verbose: u8,
30
31    /// Suppress non-error output
32    #[arg(short, long, global = true)]
33    quiet: bool,
34
35    /// Disable colored output
36    #[arg(long, global = true)]
37    no_color: bool,
38
39    /// Target remote host (overrides default_host)
40    #[arg(long, global = true)]
41    host: Option<String>,
42}
43
44#[derive(Subcommand)]
45enum Commands {
46    /// Start the opencode service
47    Start(commands::StartArgs),
48    /// Stop the opencode service
49    Stop(commands::StopArgs),
50    /// Restart the opencode service
51    Restart(commands::RestartArgs),
52    /// Show service status
53    Status(commands::StatusArgs),
54    /// View service logs
55    Logs(commands::LogsArgs),
56    /// Register service to start on boot/login
57    Install(commands::InstallArgs),
58    /// Remove service registration
59    Uninstall(commands::UninstallArgs),
60    /// Manage configuration
61    Config(commands::ConfigArgs),
62    /// Run interactive setup wizard
63    Setup(commands::SetupArgs),
64    /// Manage container users
65    User(commands::UserArgs),
66    /// Update to the latest version or rollback
67    Update(commands::UpdateArgs),
68    /// Open Cockpit web console
69    Cockpit(commands::CockpitArgs),
70    /// Manage remote hosts
71    Host(commands::HostArgs),
72}
73
74/// Get the ASCII banner for help display
75fn get_banner() -> &'static str {
76    r#"
77  ___  _ __   ___ _ __   ___ ___   __| | ___
78 / _ \| '_ \ / _ \ '_ \ / __/ _ \ / _` |/ _ \
79| (_) | |_) |  __/ | | | (_| (_) | (_| |  __/
80 \___/| .__/ \___|_| |_|\___\___/ \__,_|\___|
81      |_|                            cloud
82"#
83}
84
85/// Resolve which Docker client to use based on --host flag and default_host config
86///
87/// Returns (DockerClient, Option<host_name>) where host_name is Some for remote connections.
88///
89/// Resolution order:
90/// 1. --host flag (explicit)
91/// 2. default_host from hosts.json
92/// 3. Local Docker (no host_name)
93pub async fn resolve_docker_client(
94    maybe_host: Option<&str>,
95) -> anyhow::Result<(DockerClient, Option<String>)> {
96    let hosts = load_hosts().unwrap_or_default();
97
98    // Determine target host
99    let target_host = maybe_host
100        .map(String::from)
101        .or_else(|| hosts.default_host.clone());
102
103    match target_host {
104        Some(name) if name != "local" && !name.is_empty() => {
105            // Remote host requested
106            let host_config = hosts.get_host(&name).ok_or_else(|| {
107                anyhow::anyhow!(
108                    "Host '{name}' not found. Run 'occ host list' to see available hosts."
109                )
110            })?;
111
112            let client = DockerClient::connect_remote(host_config, &name).await?;
113            Ok((client, Some(name)))
114        }
115        _ => {
116            // Local Docker
117            let client = DockerClient::new()?;
118            Ok((client, None))
119        }
120    }
121}
122
123/// Format a message with optional host prefix
124///
125/// For remote hosts: "[prod-1] Starting container..."
126/// For local: "Starting container..."
127pub fn format_host_message(host_name: Option<&str>, message: &str) -> String {
128    match host_name {
129        Some(name) => format!("[{}] {}", style(name).cyan(), message),
130        None => message.to_string(),
131    }
132}
133
134pub fn run() -> Result<()> {
135    // Initialize tracing
136    tracing_subscriber::fmt::init();
137
138    let cli = Cli::parse();
139
140    // Configure color output
141    if cli.no_color {
142        console::set_colors_enabled(false);
143    }
144
145    // Load config (creates default if missing)
146    let config_path = config::paths::get_config_path()
147        .ok_or_else(|| anyhow::anyhow!("Could not determine config path"))?;
148
149    let config = match load_config() {
150        Ok(config) => {
151            // If config was just created, inform the user
152            if cli.verbose > 0 {
153                eprintln!(
154                    "{} Config loaded from: {}",
155                    style("[info]").cyan(),
156                    config_path.display()
157                );
158            }
159            config
160        }
161        Err(e) => {
162            // Display rich error for invalid config
163            eprintln!("{} Configuration error", style("Error:").red().bold());
164            eprintln!();
165            eprintln!("  {e}");
166            eprintln!();
167            eprintln!("  Config file: {}", style(config_path.display()).yellow());
168            eprintln!();
169            eprintln!(
170                "  {} Check the config file for syntax errors or unknown fields.",
171                style("Tip:").cyan()
172            );
173            eprintln!(
174                "  {} See schemas/config.example.jsonc for valid configuration.",
175                style("Tip:").cyan()
176            );
177            std::process::exit(1);
178        }
179    };
180
181    // Show verbose info if requested
182    if cli.verbose > 0 {
183        let data_dir = config::paths::get_data_dir()
184            .map(|p| p.display().to_string())
185            .unwrap_or_else(|| "unknown".to_string());
186        eprintln!(
187            "{} Config: {}",
188            style("[info]").cyan(),
189            config_path.display()
190        );
191        eprintln!("{} Data: {}", style("[info]").cyan(), data_dir);
192    }
193
194    // Store host flag for command handlers
195    let target_host = cli.host.clone();
196
197    // Check if wizard needed (missing auth and not running setup/config command)
198    let needs_wizard = !config.has_required_auth()
199        && !matches!(
200            cli.command,
201            Some(Commands::Setup(_)) | Some(Commands::Config(_))
202        );
203
204    if needs_wizard {
205        eprintln!(
206            "{} First-time setup required. Running wizard...",
207            style("Note:").cyan()
208        );
209        eprintln!();
210        let rt = tokio::runtime::Runtime::new()?;
211        let new_config = rt.block_on(wizard::run_wizard(Some(&config)))?;
212        save_config(&new_config)?;
213        eprintln!();
214        eprintln!(
215            "{} Setup complete! Run your command again, or use 'occ start' to begin.",
216            style("Success:").green().bold()
217        );
218        return Ok(());
219    }
220
221    match cli.command {
222        Some(Commands::Start(args)) => {
223            let rt = tokio::runtime::Runtime::new()?;
224            rt.block_on(commands::cmd_start(
225                &args,
226                target_host.as_deref(),
227                cli.quiet,
228                cli.verbose,
229            ))
230        }
231        Some(Commands::Stop(args)) => {
232            let rt = tokio::runtime::Runtime::new()?;
233            rt.block_on(commands::cmd_stop(&args, target_host.as_deref(), cli.quiet))
234        }
235        Some(Commands::Restart(args)) => {
236            let rt = tokio::runtime::Runtime::new()?;
237            rt.block_on(commands::cmd_restart(
238                &args,
239                target_host.as_deref(),
240                cli.quiet,
241                cli.verbose,
242            ))
243        }
244        Some(Commands::Status(args)) => {
245            let rt = tokio::runtime::Runtime::new()?;
246            rt.block_on(commands::cmd_status(
247                &args,
248                target_host.as_deref(),
249                cli.quiet,
250                cli.verbose,
251            ))
252        }
253        Some(Commands::Logs(args)) => {
254            let rt = tokio::runtime::Runtime::new()?;
255            rt.block_on(commands::cmd_logs(&args, target_host.as_deref(), cli.quiet))
256        }
257        Some(Commands::Install(args)) => {
258            let rt = tokio::runtime::Runtime::new()?;
259            rt.block_on(commands::cmd_install(&args, cli.quiet, cli.verbose))
260        }
261        Some(Commands::Uninstall(args)) => {
262            let rt = tokio::runtime::Runtime::new()?;
263            rt.block_on(commands::cmd_uninstall(&args, cli.quiet, cli.verbose))
264        }
265        Some(Commands::Config(cmd)) => commands::cmd_config(cmd, &config, cli.quiet),
266        Some(Commands::Setup(args)) => {
267            let rt = tokio::runtime::Runtime::new()?;
268            rt.block_on(commands::cmd_setup(&args, cli.quiet))
269        }
270        Some(Commands::User(args)) => {
271            let rt = tokio::runtime::Runtime::new()?;
272            rt.block_on(commands::cmd_user(
273                &args,
274                target_host.as_deref(),
275                cli.quiet,
276                cli.verbose,
277            ))
278        }
279        Some(Commands::Update(args)) => {
280            let rt = tokio::runtime::Runtime::new()?;
281            rt.block_on(commands::cmd_update(
282                &args,
283                target_host.as_deref(),
284                cli.quiet,
285                cli.verbose,
286            ))
287        }
288        Some(Commands::Cockpit(args)) => {
289            let rt = tokio::runtime::Runtime::new()?;
290            rt.block_on(commands::cmd_cockpit(
291                &args,
292                target_host.as_deref(),
293                cli.quiet,
294            ))
295        }
296        Some(Commands::Host(args)) => {
297            let rt = tokio::runtime::Runtime::new()?;
298            rt.block_on(commands::cmd_host(&args, cli.quiet, cli.verbose))
299        }
300        None => {
301            // No command - show a welcome message and hint to use --help
302            if !cli.quiet {
303                println!(
304                    "{} {}",
305                    style("opencode-cloud").cyan().bold(),
306                    style(get_version()).dim()
307                );
308                println!();
309                println!("Run {} for available commands.", style("--help").green());
310            }
311            Ok(())
312        }
313    }
314}
315
316/// Acquire the singleton lock for service management commands
317///
318/// This should be called before any command that manages the service
319/// (start, stop, restart, status, etc.) to ensure only one instance runs.
320/// Config commands don't need the lock as they're read-only or file-based.
321#[allow(dead_code)]
322fn acquire_singleton_lock() -> Result<InstanceLock, SingletonError> {
323    let pid_path = config::paths::get_data_dir()
324        .ok_or(SingletonError::InvalidPath)?
325        .join("opencode-cloud.pid");
326
327    InstanceLock::acquire(pid_path)
328}
329
330/// Display a rich error message when another instance is already running
331#[allow(dead_code)]
332fn display_singleton_error(err: &SingletonError) {
333    match err {
334        SingletonError::AlreadyRunning(pid) => {
335            eprintln!(
336                "{} Another instance is already running",
337                style("Error:").red().bold()
338            );
339            eprintln!();
340            eprintln!("  Process ID: {}", style(pid).yellow());
341            eprintln!();
342            eprintln!(
343                "  {} Stop the existing instance first:",
344                style("Tip:").cyan()
345            );
346            eprintln!("       {} stop", style("opencode-cloud").green());
347            eprintln!();
348            eprintln!(
349                "  {} If the process is stuck, kill it manually:",
350                style("Tip:").cyan()
351            );
352            eprintln!("       {} {}", style("kill").green(), pid);
353        }
354        SingletonError::CreateDirFailed(msg) => {
355            eprintln!(
356                "{} Failed to create data directory",
357                style("Error:").red().bold()
358            );
359            eprintln!();
360            eprintln!("  {msg}");
361            eprintln!();
362            if let Some(data_dir) = config::paths::get_data_dir() {
363                eprintln!("  {} Check permissions for:", style("Tip:").cyan());
364                eprintln!("       {}", style(data_dir.display()).yellow());
365            }
366        }
367        SingletonError::LockFailed(msg) => {
368            eprintln!("{} Failed to acquire lock", style("Error:").red().bold());
369            eprintln!();
370            eprintln!("  {msg}");
371        }
372        SingletonError::InvalidPath => {
373            eprintln!(
374                "{} Could not determine lock file path",
375                style("Error:").red().bold()
376            );
377            eprintln!();
378            eprintln!(
379                "  {} Ensure XDG_DATA_HOME or HOME is set.",
380                style("Tip:").cyan()
381            );
382        }
383    }
384}