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_or_default,
17    load_hosts, 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    let skip_wizard = matches!(
161        cli.command,
162        Some(Commands::Setup(ref args)) if args.bootstrap || args.yes
163    );
164
165    if !config_exists && !skip_wizard {
166        eprintln!(
167            "{} First-time setup required. Running wizard...",
168            style("Note:").cyan()
169        );
170        eprintln!();
171        let rt = tokio::runtime::Runtime::new()?;
172        let new_config = rt.block_on(wizard::run_wizard(None))?;
173        save_config(&new_config)?;
174        eprintln!();
175        eprintln!(
176            "{} Setup complete! Run your command again, or use 'occ start' to begin.",
177            style("Success:").green().bold()
178        );
179        return Ok(());
180    }
181
182    // Load config
183    let config = match load_config_or_default() {
184        Ok(config) => {
185            // If config was just created, inform the user
186            if cli.verbose > 0 {
187                eprintln!(
188                    "{} Config loaded from: {}",
189                    style("[info]").cyan(),
190                    config_path.display()
191                );
192            }
193            config
194        }
195        Err(e) => {
196            // Display rich error for invalid config
197            eprintln!("{} Configuration error", style("Error:").red().bold());
198            eprintln!();
199            eprintln!("  {e}");
200            eprintln!();
201            eprintln!("  Config file: {}", style(config_path.display()).yellow());
202            eprintln!();
203            eprintln!(
204                "  {} Check the config file for syntax errors or unknown fields.",
205                style("Tip:").cyan()
206            );
207            eprintln!(
208                "  {} See schemas/config.example.jsonc for valid configuration.",
209                style("Tip:").cyan()
210            );
211            std::process::exit(1);
212        }
213    };
214
215    // Show verbose info if requested
216    if cli.verbose > 0 {
217        let data_dir = config::paths::get_data_dir()
218            .map(|p| p.display().to_string())
219            .unwrap_or_else(|| "unknown".to_string());
220        eprintln!(
221            "{} Config: {}",
222            style("[info]").cyan(),
223            config_path.display()
224        );
225        eprintln!("{} Data: {}", style("[info]").cyan(), data_dir);
226    }
227
228    // Store host flag for command handlers
229    let target_host = cli.host.clone();
230
231    match cli.command {
232        Some(Commands::Start(args)) => {
233            let rt = tokio::runtime::Runtime::new()?;
234            rt.block_on(commands::cmd_start(
235                &args,
236                target_host.as_deref(),
237                cli.quiet,
238                cli.verbose,
239            ))
240        }
241        Some(Commands::Stop(args)) => {
242            let rt = tokio::runtime::Runtime::new()?;
243            rt.block_on(commands::cmd_stop(&args, target_host.as_deref(), cli.quiet))
244        }
245        Some(Commands::Restart(args)) => {
246            let rt = tokio::runtime::Runtime::new()?;
247            rt.block_on(commands::cmd_restart(
248                &args,
249                target_host.as_deref(),
250                cli.quiet,
251                cli.verbose,
252            ))
253        }
254        Some(Commands::Status(args)) => {
255            let rt = tokio::runtime::Runtime::new()?;
256            rt.block_on(commands::cmd_status(
257                &args,
258                target_host.as_deref(),
259                cli.quiet,
260                cli.verbose,
261            ))
262        }
263        Some(Commands::Logs(args)) => {
264            let rt = tokio::runtime::Runtime::new()?;
265            rt.block_on(commands::cmd_logs(&args, target_host.as_deref(), cli.quiet))
266        }
267        Some(Commands::Install(args)) => {
268            let rt = tokio::runtime::Runtime::new()?;
269            rt.block_on(commands::cmd_install(&args, cli.quiet, cli.verbose))
270        }
271        Some(Commands::Uninstall(args)) => {
272            let rt = tokio::runtime::Runtime::new()?;
273            rt.block_on(commands::cmd_uninstall(&args, cli.quiet, cli.verbose))
274        }
275        Some(Commands::Config(cmd)) => commands::cmd_config(cmd, &config, cli.quiet),
276        Some(Commands::Setup(args)) => {
277            let rt = tokio::runtime::Runtime::new()?;
278            rt.block_on(commands::cmd_setup(&args, cli.quiet))
279        }
280        Some(Commands::User(args)) => {
281            let rt = tokio::runtime::Runtime::new()?;
282            rt.block_on(commands::cmd_user(
283                &args,
284                target_host.as_deref(),
285                cli.quiet,
286                cli.verbose,
287            ))
288        }
289        Some(Commands::Mount(args)) => {
290            let rt = tokio::runtime::Runtime::new()?;
291            rt.block_on(commands::cmd_mount(&args, cli.quiet, cli.verbose))
292        }
293        Some(Commands::Update(args)) => {
294            let rt = tokio::runtime::Runtime::new()?;
295            rt.block_on(commands::cmd_update(
296                &args,
297                target_host.as_deref(),
298                cli.quiet,
299                cli.verbose,
300            ))
301        }
302        Some(Commands::Cockpit(args)) => {
303            let rt = tokio::runtime::Runtime::new()?;
304            rt.block_on(commands::cmd_cockpit(
305                &args,
306                target_host.as_deref(),
307                cli.quiet,
308            ))
309        }
310        Some(Commands::Host(args)) => {
311            let rt = tokio::runtime::Runtime::new()?;
312            rt.block_on(commands::cmd_host(&args, cli.quiet, cli.verbose))
313        }
314        None => {
315            let rt = tokio::runtime::Runtime::new()?;
316            rt.block_on(handle_no_command(
317                target_host.as_deref(),
318                cli.quiet,
319                cli.verbose,
320            ))
321        }
322    }
323}
324
325async fn handle_no_command(target_host: Option<&str>, quiet: bool, verbose: u8) -> Result<()> {
326    if quiet {
327        return Ok(());
328    }
329
330    let (client, host_name) = resolve_docker_client(target_host).await?;
331    client
332        .verify_connection()
333        .await
334        .map_err(|e| anyhow!("Docker connection error: {e}"))?;
335
336    let running = opencode_cloud_core::docker::container_is_running(
337        &client,
338        opencode_cloud_core::docker::CONTAINER_NAME,
339    )
340    .await
341    .map_err(|e| anyhow!("Docker error: {e}"))?;
342
343    if running {
344        let status_args = commands::StatusArgs {};
345        return commands::cmd_status(&status_args, host_name.as_deref(), quiet, verbose).await;
346    }
347
348    eprintln!("{} Service is not running.", style("Note:").yellow());
349
350    let confirmed = Confirm::new()
351        .with_prompt("Start the service now?")
352        .default(true)
353        .interact()?;
354
355    if confirmed {
356        let start_args = commands::StartArgs {
357            port: None,
358            open: false,
359            no_daemon: false,
360            pull_sandbox_image: false,
361            cached_rebuild_sandbox_image: false,
362            full_rebuild_sandbox_image: false,
363            ignore_version: false,
364            no_update_check: false,
365            mounts: Vec::new(),
366            no_mounts: false,
367        };
368        commands::cmd_start(&start_args, host_name.as_deref(), quiet, verbose).await?;
369        let status_args = commands::StatusArgs {};
370        return commands::cmd_status(&status_args, host_name.as_deref(), quiet, verbose).await;
371    }
372
373    print_help_hint();
374    Ok(())
375}
376
377fn print_help_hint() {
378    println!(
379        "{} {}",
380        style("opencode-cloud").cyan().bold(),
381        style(get_version()).dim()
382    );
383    println!();
384    println!("Run {} for available commands.", style("--help").green());
385}
386
387/// Acquire the singleton lock for service management commands
388///
389/// This should be called before any command that manages the service
390/// (start, stop, restart, status, etc.) to ensure only one instance runs.
391/// Config commands don't need the lock as they're read-only or file-based.
392#[allow(dead_code)]
393fn acquire_singleton_lock() -> Result<InstanceLock, SingletonError> {
394    let pid_path = config::paths::get_data_dir()
395        .ok_or(SingletonError::InvalidPath)?
396        .join("opencode-cloud.pid");
397
398    InstanceLock::acquire(pid_path)
399}
400
401/// Display a rich error message when another instance is already running
402#[allow(dead_code)]
403fn display_singleton_error(err: &SingletonError) {
404    match err {
405        SingletonError::AlreadyRunning(pid) => {
406            eprintln!(
407                "{} Another instance is already running",
408                style("Error:").red().bold()
409            );
410            eprintln!();
411            eprintln!("  Process ID: {}", style(pid).yellow());
412            eprintln!();
413            eprintln!(
414                "  {} Stop the existing instance first:",
415                style("Tip:").cyan()
416            );
417            eprintln!("       {} stop", style("opencode-cloud").green());
418            eprintln!();
419            eprintln!(
420                "  {} If the process is stuck, kill it manually:",
421                style("Tip:").cyan()
422            );
423            eprintln!("       {} {}", style("kill").green(), pid);
424        }
425        SingletonError::CreateDirFailed(msg) => {
426            eprintln!(
427                "{} Failed to create data directory",
428                style("Error:").red().bold()
429            );
430            eprintln!();
431            eprintln!("  {msg}");
432            eprintln!();
433            if let Some(data_dir) = config::paths::get_data_dir() {
434                eprintln!("  {} Check permissions for:", style("Tip:").cyan());
435                eprintln!("       {}", style(data_dir.display()).yellow());
436            }
437        }
438        SingletonError::LockFailed(msg) => {
439            eprintln!("{} Failed to acquire lock", style("Error:").red().bold());
440            eprintln!();
441            eprintln!("  {msg}");
442        }
443        SingletonError::InvalidPath => {
444            eprintln!(
445                "{} Could not determine lock file path",
446                style("Error:").red().bold()
447            );
448            eprintln!();
449            eprintln!(
450                "  {} Ensure XDG_DATA_HOME or HOME is set.",
451                style("Tip:").cyan()
452            );
453        }
454    }
455}