1mod 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#[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 #[arg(short, long, global = true, action = clap::ArgAction::Count)]
34 verbose: u8,
35
36 #[arg(short, long, global = true)]
38 quiet: bool,
39
40 #[arg(long, global = true)]
42 no_color: bool,
43
44 #[arg(long, global = true, conflicts_with = "local")]
46 remote_host: Option<String>,
47
48 #[arg(long, global = true, conflicts_with = "remote_host")]
50 local: bool,
51
52 #[arg(long, global = true, value_enum)]
54 runtime: Option<RuntimeChoice>,
55}
56
57#[derive(Subcommand)]
58enum Commands {
59 Start(commands::StartArgs),
61 Stop(commands::StopArgs),
63 Restart(commands::RestartArgs),
65 Status(commands::StatusArgs),
67 Logs(commands::LogsArgs),
69 Install(commands::InstallArgs),
71 Uninstall(commands::UninstallArgs),
73 Config(commands::ConfigArgs),
75 Setup(commands::SetupArgs),
77 User(commands::UserArgs),
79 Mount(commands::MountArgs),
81 Reset(commands::ResetArgs),
83 Update(commands::UpdateArgs),
85 #[command(hide = true)]
87 Cockpit(commands::CockpitArgs),
88 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
105fn get_banner() -> &'static str {
107 r#"
108 ___ _ __ ___ _ __ ___ ___ __| | ___
109 / _ \| '_ \ / _ \ '_ \ / __/ _ \ / _` |/ _ \
110| (_) | |_) | __/ | | | (_| (_) | (_| | __/
111 \___/| .__/ \___|_| |_|\___\___/ \__,_|\___|
112 |_| cloud
113"#
114}
115
116pub 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
136pub 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 let target_host = maybe_host.map(String::from);
146
147 match target_host {
148 Some(name) => {
149 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 let client = DockerClient::new()?;
162 Ok((client, None))
163 }
164 }
165}
166
167pub 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 tracing_subscriber::fmt::init();
268
269 let cli = Cli::parse();
270
271 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 let config = match load_config_or_default() {
337 Ok(config) => {
338 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 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 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 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#[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#[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}