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 Update(commands::UpdateArgs),
73 #[command(hide = true)]
75 Cockpit(commands::CockpitArgs),
76 Host(commands::HostArgs),
78}
79
80fn get_banner() -> &'static str {
82 r#"
83 ___ _ __ ___ _ __ ___ ___ __| | ___
84 / _ \| '_ \ / _ \ '_ \ / __/ _ \ / _` |/ _ \
85| (_) | |_) | __/ | | | (_| (_) | (_| | __/
86 \___/| .__/ \___|_| |_|\___\___/ \__,_|\___|
87 |_| cloud
88"#
89}
90
91pub async fn resolve_docker_client(
100 maybe_host: Option<&str>,
101) -> anyhow::Result<(DockerClient, Option<String>)> {
102 let hosts = load_hosts().unwrap_or_default();
103
104 let target_host = maybe_host
106 .map(String::from)
107 .or_else(|| hosts.default_host.clone());
108
109 match target_host {
110 Some(name) if name != "local" && !name.is_empty() => {
111 let host_config = hosts.get_host(&name).ok_or_else(|| {
113 anyhow::anyhow!(
114 "Host '{name}' not found. Run 'occ host list' to see available hosts."
115 )
116 })?;
117
118 let client = DockerClient::connect_remote(host_config, &name).await?;
119 Ok((client, Some(name)))
120 }
121 _ => {
122 let client = DockerClient::new()?;
124 Ok((client, None))
125 }
126 }
127}
128
129pub fn format_host_message(host_name: Option<&str>, message: &str) -> String {
134 match host_name {
135 Some(name) => format!("[{}] {}", style(name).cyan(), message),
136 None => message.to_string(),
137 }
138}
139
140pub fn run() -> Result<()> {
141 tracing_subscriber::fmt::init();
143
144 let cli = Cli::parse();
145
146 if cli.no_color {
148 console::set_colors_enabled(false);
149 }
150
151 eprintln!(
152 "{} 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.",
153 style("Warning:").yellow().bold()
154 );
155 eprintln!();
156
157 let config_path = config::paths::get_config_path()
158 .ok_or_else(|| anyhow!("Could not determine config path"))?;
159 let config_exists = config_path.exists();
160
161 let skip_wizard = matches!(
162 cli.command,
163 Some(Commands::Setup(ref args)) if args.bootstrap || args.yes
164 );
165
166 if !config_exists && !skip_wizard {
167 eprintln!(
168 "{} First-time setup required. Running wizard...",
169 style("Note:").cyan()
170 );
171 eprintln!();
172 let rt = tokio::runtime::Runtime::new()?;
173 let new_config = rt.block_on(wizard::run_wizard(None))?;
174 save_config(&new_config)?;
175 eprintln!();
176 eprintln!(
177 "{} Setup complete! Run your command again, or use 'occ start' to begin.",
178 style("Success:").green().bold()
179 );
180 return Ok(());
181 }
182
183 let config = match load_config_or_default() {
185 Ok(config) => {
186 if cli.verbose > 0 {
188 eprintln!(
189 "{} Config loaded from: {}",
190 style("[info]").cyan(),
191 config_path.display()
192 );
193 }
194 config
195 }
196 Err(e) => {
197 eprintln!("{} Configuration error", style("Error:").red().bold());
199 eprintln!();
200 eprintln!(" {e}");
201 eprintln!();
202 eprintln!(" Config file: {}", style(config_path.display()).yellow());
203 eprintln!();
204 eprintln!(
205 " {} Check the config file for syntax errors or unknown fields.",
206 style("Tip:").cyan()
207 );
208 eprintln!(
209 " {} See schemas/config.example.jsonc for valid configuration.",
210 style("Tip:").cyan()
211 );
212 std::process::exit(1);
213 }
214 };
215
216 if cli.verbose > 0 {
218 let data_dir = config::paths::get_data_dir()
219 .map(|p| p.display().to_string())
220 .unwrap_or_else(|| "unknown".to_string());
221 eprintln!(
222 "{} Config: {}",
223 style("[info]").cyan(),
224 config_path.display()
225 );
226 eprintln!("{} Data: {}", style("[info]").cyan(), data_dir);
227 }
228
229 let target_host = cli.host.clone();
231
232 match cli.command {
233 Some(Commands::Start(args)) => {
234 let rt = tokio::runtime::Runtime::new()?;
235 rt.block_on(commands::cmd_start(
236 &args,
237 target_host.as_deref(),
238 cli.quiet,
239 cli.verbose,
240 ))
241 }
242 Some(Commands::Stop(args)) => {
243 let rt = tokio::runtime::Runtime::new()?;
244 rt.block_on(commands::cmd_stop(&args, target_host.as_deref(), cli.quiet))
245 }
246 Some(Commands::Restart(args)) => {
247 let rt = tokio::runtime::Runtime::new()?;
248 rt.block_on(commands::cmd_restart(
249 &args,
250 target_host.as_deref(),
251 cli.quiet,
252 cli.verbose,
253 ))
254 }
255 Some(Commands::Status(args)) => {
256 let rt = tokio::runtime::Runtime::new()?;
257 rt.block_on(commands::cmd_status(
258 &args,
259 target_host.as_deref(),
260 cli.quiet,
261 cli.verbose,
262 ))
263 }
264 Some(Commands::Logs(args)) => {
265 let rt = tokio::runtime::Runtime::new()?;
266 rt.block_on(commands::cmd_logs(&args, target_host.as_deref(), cli.quiet))
267 }
268 Some(Commands::Install(args)) => {
269 let rt = tokio::runtime::Runtime::new()?;
270 rt.block_on(commands::cmd_install(&args, cli.quiet, cli.verbose))
271 }
272 Some(Commands::Uninstall(args)) => {
273 let rt = tokio::runtime::Runtime::new()?;
274 rt.block_on(commands::cmd_uninstall(&args, cli.quiet, cli.verbose))
275 }
276 Some(Commands::Config(cmd)) => commands::cmd_config(cmd, &config, cli.quiet),
277 Some(Commands::Setup(args)) => {
278 let rt = tokio::runtime::Runtime::new()?;
279 rt.block_on(commands::cmd_setup(&args, cli.quiet))
280 }
281 Some(Commands::User(args)) => {
282 let rt = tokio::runtime::Runtime::new()?;
283 rt.block_on(commands::cmd_user(
284 &args,
285 target_host.as_deref(),
286 cli.quiet,
287 cli.verbose,
288 ))
289 }
290 Some(Commands::Mount(args)) => {
291 let rt = tokio::runtime::Runtime::new()?;
292 rt.block_on(commands::cmd_mount(&args, cli.quiet, cli.verbose))
293 }
294 Some(Commands::Update(args)) => {
295 let rt = tokio::runtime::Runtime::new()?;
296 rt.block_on(commands::cmd_update(
297 &args,
298 target_host.as_deref(),
299 cli.quiet,
300 cli.verbose,
301 ))
302 }
303 Some(Commands::Cockpit(args)) => {
304 let rt = tokio::runtime::Runtime::new()?;
305 rt.block_on(commands::cmd_cockpit(
306 &args,
307 target_host.as_deref(),
308 cli.quiet,
309 ))
310 }
311 Some(Commands::Host(args)) => {
312 let rt = tokio::runtime::Runtime::new()?;
313 rt.block_on(commands::cmd_host(&args, cli.quiet, cli.verbose))
314 }
315 None => {
316 let rt = tokio::runtime::Runtime::new()?;
317 rt.block_on(handle_no_command(
318 target_host.as_deref(),
319 cli.quiet,
320 cli.verbose,
321 ))
322 }
323 }
324}
325
326async fn handle_no_command(target_host: Option<&str>, quiet: bool, verbose: u8) -> Result<()> {
327 if quiet {
328 return Ok(());
329 }
330
331 let (client, host_name) = resolve_docker_client(target_host).await?;
332 client
333 .verify_connection()
334 .await
335 .map_err(|e| anyhow!("Docker connection error: {e}"))?;
336
337 let running = opencode_cloud_core::docker::container_is_running(
338 &client,
339 opencode_cloud_core::docker::CONTAINER_NAME,
340 )
341 .await
342 .map_err(|e| anyhow!("Docker error: {e}"))?;
343
344 if running {
345 let status_args = commands::StatusArgs {};
346 return commands::cmd_status(&status_args, host_name.as_deref(), quiet, verbose).await;
347 }
348
349 eprintln!("{} Service is not running.", style("Note:").yellow());
350
351 let confirmed = Confirm::new()
352 .with_prompt("Start the service now?")
353 .default(true)
354 .interact()?;
355
356 if confirmed {
357 let start_args = commands::StartArgs {
358 port: None,
359 open: false,
360 no_daemon: false,
361 pull_sandbox_image: false,
362 cached_rebuild_sandbox_image: false,
363 full_rebuild_sandbox_image: false,
364 ignore_version: false,
365 no_update_check: false,
366 mounts: Vec::new(),
367 no_mounts: false,
368 };
369 commands::cmd_start(&start_args, host_name.as_deref(), quiet, verbose).await?;
370 let status_args = commands::StatusArgs {};
371 return commands::cmd_status(&status_args, host_name.as_deref(), quiet, verbose).await;
372 }
373
374 print_help_hint();
375 Ok(())
376}
377
378fn print_help_hint() {
379 println!(
380 "{} {}",
381 style("opencode-cloud").cyan().bold(),
382 style(get_version()).dim()
383 );
384 println!();
385 println!("Run {} for available commands.", style("--help").green());
386}
387
388#[allow(dead_code)]
394fn acquire_singleton_lock() -> Result<InstanceLock, SingletonError> {
395 let pid_path = config::paths::get_data_dir()
396 .ok_or(SingletonError::InvalidPath)?
397 .join("opencode-cloud.pid");
398
399 InstanceLock::acquire(pid_path)
400}
401
402#[allow(dead_code)]
404fn display_singleton_error(err: &SingletonError) {
405 match err {
406 SingletonError::AlreadyRunning(pid) => {
407 eprintln!(
408 "{} Another instance is already running",
409 style("Error:").red().bold()
410 );
411 eprintln!();
412 eprintln!(" Process ID: {}", style(pid).yellow());
413 eprintln!();
414 eprintln!(
415 " {} Stop the existing instance first:",
416 style("Tip:").cyan()
417 );
418 eprintln!(" {} stop", style("opencode-cloud").green());
419 eprintln!();
420 eprintln!(
421 " {} If the process is stuck, kill it manually:",
422 style("Tip:").cyan()
423 );
424 eprintln!(" {} {}", style("kill").green(), pid);
425 }
426 SingletonError::CreateDirFailed(msg) => {
427 eprintln!(
428 "{} Failed to create data directory",
429 style("Error:").red().bold()
430 );
431 eprintln!();
432 eprintln!(" {msg}");
433 eprintln!();
434 if let Some(data_dir) = config::paths::get_data_dir() {
435 eprintln!(" {} Check permissions for:", style("Tip:").cyan());
436 eprintln!(" {}", style(data_dir.display()).yellow());
437 }
438 }
439 SingletonError::LockFailed(msg) => {
440 eprintln!("{} Failed to acquire lock", style("Error:").red().bold());
441 eprintln!();
442 eprintln!(" {msg}");
443 }
444 SingletonError::InvalidPath => {
445 eprintln!(
446 "{} Could not determine lock file path",
447 style("Error:").red().bold()
448 );
449 eprintln!();
450 eprintln!(
451 " {} Ensure XDG_DATA_HOME or HOME is set.",
452 style("Tip:").cyan()
453 );
454 }
455 }
456}