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