1mod 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#[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 #[arg(short, long, global = true, action = clap::ArgAction::Count)]
32 verbose: u8,
33
34 #[arg(short, long, global = true)]
36 quiet: bool,
37
38 #[arg(long, global = true)]
40 no_color: bool,
41
42 #[arg(long, global = true)]
44 host: Option<String>,
45}
46
47#[derive(Subcommand)]
48enum Commands {
49 Start(commands::StartArgs),
51 Stop(commands::StopArgs),
53 Restart(commands::RestartArgs),
55 Status(commands::StatusArgs),
57 Logs(commands::LogsArgs),
59 Install(commands::InstallArgs),
61 Uninstall(commands::UninstallArgs),
63 Config(commands::ConfigArgs),
65 Setup(commands::SetupArgs),
67 User(commands::UserArgs),
69 Mount(commands::MountArgs),
71 Reset(commands::ResetArgs),
73 Update(commands::UpdateArgs),
75 #[command(hide = true)]
77 Cockpit(commands::CockpitArgs),
78 Host(commands::HostArgs),
80}
81
82fn get_banner() -> &'static str {
84 r#"
85 ___ _ __ ___ _ __ ___ ___ __| | ___
86 / _ \| '_ \ / _ \ '_ \ / __/ _ \ / _` |/ _ \
87| (_) | |_) | __/ | | | (_| (_) | (_| | __/
88 \___/| .__/ \___|_| |_|\___\___/ \__,_|\___|
89 |_| cloud
90"#
91}
92
93pub 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 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 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 let client = DockerClient::new()?;
126 Ok((client, None))
127 }
128 }
129}
130
131pub 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 tracing_subscriber::fmt::init();
145
146 let cli = Cli::parse();
147
148 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 let config = match load_config_or_default() {
187 Ok(config) => {
188 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 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 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 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#[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#[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}