1mod 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#[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 #[arg(short, long, global = true, action = clap::ArgAction::Count)]
33 verbose: u8,
34
35 #[arg(short, long, global = true)]
37 quiet: bool,
38
39 #[arg(long, global = true)]
41 no_color: bool,
42
43 #[arg(long, global = true, conflicts_with = "local")]
45 remote_host: Option<String>,
46
47 #[arg(long, global = true, conflicts_with = "remote_host")]
49 local: bool,
50}
51
52#[derive(Subcommand)]
53enum Commands {
54 Start(commands::StartArgs),
56 Stop(commands::StopArgs),
58 Restart(commands::RestartArgs),
60 Status(commands::StatusArgs),
62 Logs(commands::LogsArgs),
64 Install(commands::InstallArgs),
66 Uninstall(commands::UninstallArgs),
68 Config(commands::ConfigArgs),
70 Setup(commands::SetupArgs),
72 User(commands::UserArgs),
74 Mount(commands::MountArgs),
76 Reset(commands::ResetArgs),
78 Update(commands::UpdateArgs),
80 #[command(hide = true)]
82 Cockpit(commands::CockpitArgs),
83 Host(commands::HostArgs),
85}
86
87fn get_banner() -> &'static str {
89 r#"
90 ___ _ __ ___ _ __ ___ ___ __| | ___
91 / _ \| '_ \ / _ \ '_ \ / __/ _ \ / _` |/ _ \
92| (_) | |_) | __/ | | | (_| (_) | (_| | __/
93 \___/| .__/ \___|_| |_|\___\___/ \__,_|\___|
94 |_| cloud
95"#
96}
97
98pub 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
118pub 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 let target_host = maybe_host.map(String::from);
128
129 match target_host {
130 Some(name) => {
131 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 let client = DockerClient::new()?;
144 Ok((client, None))
145 }
146 }
147}
148
149pub 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 tracing_subscriber::fmt::init();
163
164 let cli = Cli::parse();
165
166 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 let config = match load_config_or_default() {
205 Ok(config) => {
206 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 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 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 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#[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#[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}