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