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