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