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