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