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