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, ValueEnum};
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};
20use std::path::Path;
21
22/// Manage your opencode cloud service
23#[derive(Parser)]
24#[command(name = "opencode-cloud")]
25#[command(version = env!("CARGO_PKG_VERSION"))]
26#[command(about = "Manage your opencode cloud service", long_about = None)]
27#[command(after_help = get_banner())]
28struct Cli {
29    #[command(subcommand)]
30    command: Option<Commands>,
31
32    /// Increase verbosity level
33    #[arg(short, long, global = true, action = clap::ArgAction::Count)]
34    verbose: u8,
35
36    /// Suppress non-error output
37    #[arg(short, long, global = true)]
38    quiet: bool,
39
40    /// Disable colored output
41    #[arg(long, global = true)]
42    no_color: bool,
43
44    /// Target remote host (overrides default_host)
45    #[arg(long, global = true, conflicts_with = "local")]
46    remote_host: Option<String>,
47
48    /// Force local Docker (ignores default_host)
49    #[arg(long, global = true, conflicts_with = "remote_host")]
50    local: bool,
51
52    /// Runtime mode (auto-detect container vs host)
53    #[arg(long, global = true, value_enum)]
54    runtime: Option<RuntimeChoice>,
55}
56
57#[derive(Subcommand)]
58enum Commands {
59    /// Start the opencode service
60    Start(commands::StartArgs),
61    /// Stop the opencode service
62    Stop(commands::StopArgs),
63    /// Restart the opencode service
64    Restart(commands::RestartArgs),
65    /// Show service status
66    Status(commands::StatusArgs),
67    /// View service logs
68    Logs(commands::LogsArgs),
69    /// Register service to start on boot/login
70    Install(commands::InstallArgs),
71    /// Remove service registration
72    Uninstall(commands::UninstallArgs),
73    /// Manage configuration
74    Config(commands::ConfigArgs),
75    /// Run interactive setup wizard
76    Setup(commands::SetupArgs),
77    /// Manage container users
78    User(commands::UserArgs),
79    /// Manage bind mounts
80    Mount(commands::MountArgs),
81    /// Reset containers, mounts, and host data
82    Reset(commands::ResetArgs),
83    /// Update to the latest version or rollback (interactive when no subcommand is provided)
84    Update(commands::UpdateArgs),
85    /// Open Cockpit web console
86    #[command(hide = true)]
87    Cockpit(commands::CockpitArgs),
88    /// Manage remote hosts
89    Host(commands::HostArgs),
90}
91
92#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
93enum RuntimeChoice {
94    Auto,
95    Host,
96    Container,
97}
98
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100enum RuntimeMode {
101    Host,
102    Container,
103}
104
105/// Get the ASCII banner for help display
106fn get_banner() -> &'static str {
107    r#"
108  ___  _ __   ___ _ __   ___ ___   __| | ___
109 / _ \| '_ \ / _ \ '_ \ / __/ _ \ / _` |/ _ \
110| (_) | |_) |  __/ | | | (_| (_) | (_| |  __/
111 \___/| .__/ \___|_| |_|\___\___/ \__,_|\___|
112      |_|                            cloud
113"#
114}
115
116/// Resolve the target host name based on flags and hosts.json
117///
118/// Resolution order:
119/// 1. --local (force local Docker)
120/// 2. --remote-host flag (explicit)
121/// 3. default_host from hosts.json
122/// 4. Local Docker (no host_name)
123pub fn resolve_target_host(remote_host: Option<&str>, force_local: bool) -> Option<String> {
124    if force_local {
125        return None;
126    }
127
128    if let Some(name) = remote_host {
129        return Some(name.to_string());
130    }
131
132    let hosts = load_hosts().unwrap_or_default();
133    hosts.default_host.clone()
134}
135
136/// Resolve which Docker client to use based on an explicit target host name
137///
138/// Returns (DockerClient, Option<host_name>) where host_name is Some for remote connections.
139pub async fn resolve_docker_client(
140    maybe_host: Option<&str>,
141) -> anyhow::Result<(DockerClient, Option<String>)> {
142    let hosts = load_hosts().unwrap_or_default();
143
144    // Determine target host
145    let target_host = maybe_host.map(String::from);
146
147    match target_host {
148        Some(name) => {
149            // Remote host requested
150            let host_config = hosts.get_host(&name).ok_or_else(|| {
151                anyhow::anyhow!(
152                    "Host '{name}' not found. Run 'occ host list' to see available hosts."
153                )
154            })?;
155
156            let client = DockerClient::connect_remote(host_config, &name).await?;
157            Ok((client, Some(name)))
158        }
159        None => {
160            // Local Docker
161            let client = DockerClient::new()?;
162            Ok((client, None))
163        }
164    }
165}
166
167/// Format a message with optional host prefix
168///
169/// For remote hosts: "[prod-1] Starting container..."
170/// For local: "Starting container..."
171pub fn format_host_message(host_name: Option<&str>, message: &str) -> String {
172    match host_name {
173        Some(name) => format!("[{}] {}", style(name).cyan(), message),
174        None => message.to_string(),
175    }
176}
177
178fn container_runtime_from_markers(is_container: bool, is_opencode_image: bool) -> bool {
179    is_container && is_opencode_image
180}
181
182fn detect_container_runtime() -> bool {
183    let is_container =
184        Path::new("/.dockerenv").exists() || Path::new("/run/.containerenv").exists();
185    let is_opencode_image = Path::new("/etc/opencode-cloud-version").exists()
186        || Path::new("/opt/opencode/COMMIT").exists();
187    container_runtime_from_markers(is_container, is_opencode_image)
188}
189
190fn runtime_choice_from_env() -> Option<RuntimeChoice> {
191    let value = std::env::var("OPENCODE_RUNTIME").ok()?;
192    match value.to_lowercase().as_str() {
193        "auto" => Some(RuntimeChoice::Auto),
194        "host" => Some(RuntimeChoice::Host),
195        "container" => Some(RuntimeChoice::Container),
196        _ => None,
197    }
198}
199
200fn resolve_runtime(choice: RuntimeChoice) -> (RuntimeMode, bool) {
201    let auto_container = detect_container_runtime();
202    resolve_runtime_with_autodetect(choice, auto_container)
203}
204
205fn resolve_runtime_with_autodetect(
206    choice: RuntimeChoice,
207    auto_container: bool,
208) -> (RuntimeMode, bool) {
209    match choice {
210        RuntimeChoice::Host => (RuntimeMode::Host, false),
211        RuntimeChoice::Container => (RuntimeMode::Container, false),
212        RuntimeChoice::Auto => {
213            let mode = if auto_container {
214                RuntimeMode::Container
215            } else {
216                RuntimeMode::Host
217            };
218            (mode, auto_container)
219        }
220    }
221}
222
223fn container_mode_unsupported_error() -> anyhow::Error {
224    anyhow!(
225        "Command not supported in container runtime.\n\
226Supported commands:\n  occ status\n  occ logs\n  occ user\n  occ update opencode\n\
227To force host runtime:\n  occ --runtime host <command>\n  OPENCODE_RUNTIME=host occ <command>"
228    )
229}
230
231fn run_container_mode(cli: &Cli) -> Result<()> {
232    let rt = tokio::runtime::Runtime::new()?;
233
234    match cli.command {
235        Some(Commands::Status(ref args)) => rt.block_on(commands::container::cmd_status_container(
236            args,
237            cli.quiet,
238            cli.verbose,
239        )),
240        Some(Commands::Logs(ref args)) => {
241            rt.block_on(commands::container::cmd_logs_container(args, cli.quiet))
242        }
243        Some(Commands::User(ref args)) => rt.block_on(commands::container::cmd_user_container(
244            args,
245            cli.quiet,
246            cli.verbose,
247        )),
248        Some(Commands::Update(ref args)) => rt.block_on(commands::container::cmd_update_container(
249            args,
250            cli.quiet,
251            cli.verbose,
252        )),
253        Some(_) => Err(container_mode_unsupported_error()),
254        None => {
255            let status_args = commands::StatusArgs {};
256            rt.block_on(commands::container::cmd_status_container(
257                &status_args,
258                cli.quiet,
259                cli.verbose,
260            ))
261        }
262    }
263}
264
265pub fn run() -> Result<()> {
266    // Initialize tracing
267    tracing_subscriber::fmt::init();
268
269    let cli = Cli::parse();
270
271    // Configure color output
272    if cli.no_color {
273        console::set_colors_enabled(false);
274    }
275
276    eprintln!(
277        "{} 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.",
278        style("Warning:").yellow().bold()
279    );
280    eprintln!();
281
282    let runtime_choice = cli
283        .runtime
284        .or_else(runtime_choice_from_env)
285        .unwrap_or(RuntimeChoice::Auto);
286    let (runtime_mode, auto_container) = resolve_runtime(runtime_choice);
287
288    if runtime_mode == RuntimeMode::Container {
289        if cli.remote_host.is_some() || cli.local {
290            return Err(anyhow!(
291                "Remote and local Docker flags are not supported in container runtime.\n\
292Use host mode instead:\n  occ --runtime host <command>"
293            ));
294        }
295
296        if auto_container && runtime_choice == RuntimeChoice::Auto && !cli.quiet {
297            eprintln!(
298                "{} Detected opencode container; using container runtime. Override with {} or {}.",
299                style("Info:").cyan(),
300                style("--runtime host").green(),
301                style("OPENCODE_RUNTIME=host").green()
302            );
303            eprintln!();
304        }
305
306        return run_container_mode(&cli);
307    }
308
309    let config_path = config::paths::get_config_path()
310        .ok_or_else(|| anyhow!("Could not determine config path"))?;
311    let config_exists = config_path.exists();
312
313    let skip_wizard = matches!(
314        cli.command,
315        Some(Commands::Setup(ref args)) if args.bootstrap || args.yes
316    );
317
318    if !config_exists && !skip_wizard {
319        eprintln!(
320            "{} First-time setup required. Running wizard...",
321            style("Note:").cyan()
322        );
323        eprintln!();
324        let rt = tokio::runtime::Runtime::new()?;
325        let new_config = rt.block_on(wizard::run_wizard(None))?;
326        save_config(&new_config)?;
327        eprintln!();
328        eprintln!(
329            "{} Setup complete! Run your command again, or use 'occ start' to begin.",
330            style("Success:").green().bold()
331        );
332        return Ok(());
333    }
334
335    // Load config
336    let config = match load_config_or_default() {
337        Ok(config) => {
338            // If config was just created, inform the user
339            if cli.verbose > 0 {
340                eprintln!(
341                    "{} Config loaded from: {}",
342                    style("[info]").cyan(),
343                    config_path.display()
344                );
345            }
346            config
347        }
348        Err(e) => {
349            // Display rich error for invalid config
350            eprintln!("{} Configuration error", style("Error:").red().bold());
351            eprintln!();
352            eprintln!("  {e}");
353            eprintln!();
354            eprintln!("  Config file: {}", style(config_path.display()).yellow());
355            eprintln!();
356            eprintln!(
357                "  {} Check the config file for syntax errors or unknown fields.",
358                style("Tip:").cyan()
359            );
360            eprintln!(
361                "  {} See schemas/config.example.jsonc for valid configuration.",
362                style("Tip:").cyan()
363            );
364            std::process::exit(1);
365        }
366    };
367
368    // Show verbose info if requested
369    if cli.verbose > 0 {
370        let data_dir = config::paths::get_data_dir()
371            .map(|p| p.display().to_string())
372            .unwrap_or_else(|| "unknown".to_string());
373        eprintln!(
374            "{} Config: {}",
375            style("[info]").cyan(),
376            config_path.display()
377        );
378        eprintln!("{} Data: {}", style("[info]").cyan(), data_dir);
379    }
380
381    // Store target host for command handlers
382    let target_host = resolve_target_host(cli.remote_host.as_deref(), cli.local);
383
384    match cli.command {
385        Some(Commands::Start(args)) => {
386            let rt = tokio::runtime::Runtime::new()?;
387            rt.block_on(commands::cmd_start(
388                &args,
389                target_host.as_deref(),
390                cli.quiet,
391                cli.verbose,
392            ))
393        }
394        Some(Commands::Stop(args)) => {
395            let rt = tokio::runtime::Runtime::new()?;
396            rt.block_on(commands::cmd_stop(&args, target_host.as_deref(), cli.quiet))
397        }
398        Some(Commands::Restart(args)) => {
399            let rt = tokio::runtime::Runtime::new()?;
400            rt.block_on(commands::cmd_restart(
401                &args,
402                target_host.as_deref(),
403                cli.quiet,
404                cli.verbose,
405            ))
406        }
407        Some(Commands::Status(args)) => {
408            let rt = tokio::runtime::Runtime::new()?;
409            rt.block_on(commands::cmd_status(
410                &args,
411                target_host.as_deref(),
412                cli.quiet,
413                cli.verbose,
414            ))
415        }
416        Some(Commands::Logs(args)) => {
417            let rt = tokio::runtime::Runtime::new()?;
418            rt.block_on(commands::cmd_logs(&args, target_host.as_deref(), cli.quiet))
419        }
420        Some(Commands::Install(args)) => {
421            let rt = tokio::runtime::Runtime::new()?;
422            rt.block_on(commands::cmd_install(&args, cli.quiet, cli.verbose))
423        }
424        Some(Commands::Uninstall(args)) => {
425            let rt = tokio::runtime::Runtime::new()?;
426            rt.block_on(commands::cmd_uninstall(&args, cli.quiet, cli.verbose))
427        }
428        Some(Commands::Config(cmd)) => commands::cmd_config(cmd, &config, cli.quiet),
429        Some(Commands::Setup(args)) => {
430            let rt = tokio::runtime::Runtime::new()?;
431            rt.block_on(commands::cmd_setup(&args, cli.quiet))
432        }
433        Some(Commands::User(args)) => {
434            let rt = tokio::runtime::Runtime::new()?;
435            rt.block_on(commands::cmd_user(
436                &args,
437                target_host.as_deref(),
438                cli.quiet,
439                cli.verbose,
440            ))
441        }
442        Some(Commands::Mount(args)) => {
443            let rt = tokio::runtime::Runtime::new()?;
444            rt.block_on(commands::cmd_mount(
445                &args,
446                target_host.as_deref(),
447                cli.quiet,
448                cli.verbose,
449            ))
450        }
451        Some(Commands::Reset(args)) => {
452            let rt = tokio::runtime::Runtime::new()?;
453            rt.block_on(commands::cmd_reset(
454                &args,
455                target_host.as_deref(),
456                cli.quiet,
457                cli.verbose,
458            ))
459        }
460        Some(Commands::Update(args)) => {
461            let rt = tokio::runtime::Runtime::new()?;
462            rt.block_on(commands::cmd_update(
463                &args,
464                target_host.as_deref(),
465                cli.quiet,
466                cli.verbose,
467            ))
468        }
469        Some(Commands::Cockpit(args)) => {
470            let rt = tokio::runtime::Runtime::new()?;
471            rt.block_on(commands::cmd_cockpit(
472                &args,
473                target_host.as_deref(),
474                cli.quiet,
475            ))
476        }
477        Some(Commands::Host(args)) => {
478            let rt = tokio::runtime::Runtime::new()?;
479            rt.block_on(commands::cmd_host(&args, cli.quiet, cli.verbose))
480        }
481        None => {
482            let rt = tokio::runtime::Runtime::new()?;
483            rt.block_on(handle_no_command(
484                target_host.as_deref(),
485                cli.quiet,
486                cli.verbose,
487            ))
488        }
489    }
490}
491
492async fn handle_no_command(target_host: Option<&str>, quiet: bool, verbose: u8) -> Result<()> {
493    if quiet {
494        return Ok(());
495    }
496
497    let (client, host_name) = resolve_docker_client(target_host).await?;
498    client
499        .verify_connection()
500        .await
501        .map_err(|e| anyhow!("Docker connection error: {e}"))?;
502
503    let running = opencode_cloud_core::docker::container_is_running(
504        &client,
505        opencode_cloud_core::docker::CONTAINER_NAME,
506    )
507    .await
508    .map_err(|e| anyhow!("Docker error: {e}"))?;
509
510    if running {
511        let status_args = commands::StatusArgs {};
512        return commands::cmd_status(&status_args, host_name.as_deref(), quiet, verbose).await;
513    }
514
515    eprintln!("{} Service is not running.", style("Note:").yellow());
516
517    let confirmed = Confirm::new()
518        .with_prompt("Start the service now?")
519        .default(true)
520        .interact()?;
521
522    if confirmed {
523        let start_args = commands::StartArgs {
524            port: None,
525            open: false,
526            no_daemon: false,
527            pull_sandbox_image: false,
528            cached_rebuild_sandbox_image: false,
529            full_rebuild_sandbox_image: false,
530            ignore_version: false,
531            no_update_check: false,
532            mounts: Vec::new(),
533            no_mounts: false,
534        };
535        commands::cmd_start(&start_args, host_name.as_deref(), quiet, verbose).await?;
536        let status_args = commands::StatusArgs {};
537        return commands::cmd_status(&status_args, host_name.as_deref(), quiet, verbose).await;
538    }
539
540    print_help_hint();
541    Ok(())
542}
543
544fn print_help_hint() {
545    println!(
546        "{} {}",
547        style("opencode-cloud").cyan().bold(),
548        style(get_version()).dim()
549    );
550    println!();
551    println!("Run {} for available commands.", style("--help").green());
552}
553
554/// Acquire the singleton lock for service management commands
555///
556/// This should be called before any command that manages the service
557/// (start, stop, restart, status, etc.) to ensure only one instance runs.
558/// Config commands don't need the lock as they're read-only or file-based.
559#[allow(dead_code)]
560fn acquire_singleton_lock() -> Result<InstanceLock, SingletonError> {
561    let pid_path = config::paths::get_data_dir()
562        .ok_or(SingletonError::InvalidPath)?
563        .join("opencode-cloud.pid");
564
565    InstanceLock::acquire(pid_path)
566}
567
568/// Display a rich error message when another instance is already running
569#[allow(dead_code)]
570fn display_singleton_error(err: &SingletonError) {
571    match err {
572        SingletonError::AlreadyRunning(pid) => {
573            eprintln!(
574                "{} Another instance is already running",
575                style("Error:").red().bold()
576            );
577            eprintln!();
578            eprintln!("  Process ID: {}", style(pid).yellow());
579            eprintln!();
580            eprintln!(
581                "  {} Stop the existing instance first:",
582                style("Tip:").cyan()
583            );
584            eprintln!("       {} stop", style("opencode-cloud").green());
585            eprintln!();
586            eprintln!(
587                "  {} If the process is stuck, kill it manually:",
588                style("Tip:").cyan()
589            );
590            eprintln!("       {} {}", style("kill").green(), pid);
591        }
592        SingletonError::CreateDirFailed(msg) => {
593            eprintln!(
594                "{} Failed to create data directory",
595                style("Error:").red().bold()
596            );
597            eprintln!();
598            eprintln!("  {msg}");
599            eprintln!();
600            if let Some(data_dir) = config::paths::get_data_dir() {
601                eprintln!("  {} Check permissions for:", style("Tip:").cyan());
602                eprintln!("       {}", style(data_dir.display()).yellow());
603            }
604        }
605        SingletonError::LockFailed(msg) => {
606            eprintln!("{} Failed to acquire lock", style("Error:").red().bold());
607            eprintln!();
608            eprintln!("  {msg}");
609        }
610        SingletonError::InvalidPath => {
611            eprintln!(
612                "{} Could not determine lock file path",
613                style("Error:").red().bold()
614            );
615            eprintln!();
616            eprintln!(
617                "  {} Ensure XDG_DATA_HOME or HOME is set.",
618                style("Tip:").cyan()
619            );
620        }
621    }
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627
628    #[test]
629    fn container_marker_logic_requires_both_markers() {
630        assert!(container_runtime_from_markers(true, true));
631        assert!(!container_runtime_from_markers(true, false));
632        assert!(!container_runtime_from_markers(false, true));
633        assert!(!container_runtime_from_markers(false, false));
634    }
635
636    #[test]
637    fn runtime_precedence_respects_explicit_choice() {
638        let (mode, auto) = resolve_runtime_with_autodetect(RuntimeChoice::Host, true);
639        assert_eq!(mode, RuntimeMode::Host);
640        assert!(!auto);
641
642        let (mode, auto) = resolve_runtime_with_autodetect(RuntimeChoice::Container, false);
643        assert_eq!(mode, RuntimeMode::Container);
644        assert!(!auto);
645    }
646
647    #[test]
648    fn runtime_auto_uses_detection() {
649        let (mode, auto) = resolve_runtime_with_autodetect(RuntimeChoice::Auto, true);
650        assert_eq!(mode, RuntimeMode::Container);
651        assert!(auto);
652
653        let (mode, auto) = resolve_runtime_with_autodetect(RuntimeChoice::Auto, false);
654        assert_eq!(mode, RuntimeMode::Host);
655        assert!(!auto);
656    }
657}