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 Cockpit(commands::CockpitArgs),
75 Host(commands::HostArgs),
77}
78
79fn get_banner() -> &'static str {
81 r#"
82 ___ _ __ ___ _ __ ___ ___ __| | ___
83 / _ \| '_ \ / _ \ '_ \ / __/ _ \ / _` |/ _ \
84| (_) | |_) | __/ | | | (_| (_) | (_| | __/
85 \___/| .__/ \___|_| |_|\___\___/ \__,_|\___|
86 |_| cloud
87"#
88}
89
90pub async fn resolve_docker_client(
99 maybe_host: Option<&str>,
100) -> anyhow::Result<(DockerClient, Option<String>)> {
101 let hosts = load_hosts().unwrap_or_default();
102
103 let target_host = maybe_host
105 .map(String::from)
106 .or_else(|| hosts.default_host.clone());
107
108 match target_host {
109 Some(name) if name != "local" && !name.is_empty() => {
110 let host_config = hosts.get_host(&name).ok_or_else(|| {
112 anyhow::anyhow!(
113 "Host '{name}' not found. Run 'occ host list' to see available hosts."
114 )
115 })?;
116
117 let client = DockerClient::connect_remote(host_config, &name).await?;
118 Ok((client, Some(name)))
119 }
120 _ => {
121 let client = DockerClient::new()?;
123 Ok((client, None))
124 }
125 }
126}
127
128pub fn format_host_message(host_name: Option<&str>, message: &str) -> String {
133 match host_name {
134 Some(name) => format!("[{}] {}", style(name).cyan(), message),
135 None => message.to_string(),
136 }
137}
138
139pub fn run() -> Result<()> {
140 tracing_subscriber::fmt::init();
142
143 let cli = Cli::parse();
144
145 if cli.no_color {
147 console::set_colors_enabled(false);
148 }
149
150 eprintln!(
151 "{} 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.",
152 style("Warning:").yellow().bold()
153 );
154 eprintln!();
155
156 let config_path = config::paths::get_config_path()
157 .ok_or_else(|| anyhow!("Could not determine config path"))?;
158 let config_exists = config_path.exists();
159
160 let skip_wizard = matches!(
161 cli.command,
162 Some(Commands::Setup(ref args)) if args.bootstrap || args.yes
163 );
164
165 if !config_exists && !skip_wizard {
166 eprintln!(
167 "{} First-time setup required. Running wizard...",
168 style("Note:").cyan()
169 );
170 eprintln!();
171 let rt = tokio::runtime::Runtime::new()?;
172 let new_config = rt.block_on(wizard::run_wizard(None))?;
173 save_config(&new_config)?;
174 eprintln!();
175 eprintln!(
176 "{} Setup complete! Run your command again, or use 'occ start' to begin.",
177 style("Success:").green().bold()
178 );
179 return Ok(());
180 }
181
182 let config = match load_config_or_default() {
184 Ok(config) => {
185 if cli.verbose > 0 {
187 eprintln!(
188 "{} Config loaded from: {}",
189 style("[info]").cyan(),
190 config_path.display()
191 );
192 }
193 config
194 }
195 Err(e) => {
196 eprintln!("{} Configuration error", style("Error:").red().bold());
198 eprintln!();
199 eprintln!(" {e}");
200 eprintln!();
201 eprintln!(" Config file: {}", style(config_path.display()).yellow());
202 eprintln!();
203 eprintln!(
204 " {} Check the config file for syntax errors or unknown fields.",
205 style("Tip:").cyan()
206 );
207 eprintln!(
208 " {} See schemas/config.example.jsonc for valid configuration.",
209 style("Tip:").cyan()
210 );
211 std::process::exit(1);
212 }
213 };
214
215 if cli.verbose > 0 {
217 let data_dir = config::paths::get_data_dir()
218 .map(|p| p.display().to_string())
219 .unwrap_or_else(|| "unknown".to_string());
220 eprintln!(
221 "{} Config: {}",
222 style("[info]").cyan(),
223 config_path.display()
224 );
225 eprintln!("{} Data: {}", style("[info]").cyan(), data_dir);
226 }
227
228 let target_host = cli.host.clone();
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 let rt = tokio::runtime::Runtime::new()?;
316 rt.block_on(handle_no_command(
317 target_host.as_deref(),
318 cli.quiet,
319 cli.verbose,
320 ))
321 }
322 }
323}
324
325async fn handle_no_command(target_host: Option<&str>, quiet: bool, verbose: u8) -> Result<()> {
326 if quiet {
327 return Ok(());
328 }
329
330 let (client, host_name) = resolve_docker_client(target_host).await?;
331 client
332 .verify_connection()
333 .await
334 .map_err(|e| anyhow!("Docker connection error: {e}"))?;
335
336 let running = opencode_cloud_core::docker::container_is_running(
337 &client,
338 opencode_cloud_core::docker::CONTAINER_NAME,
339 )
340 .await
341 .map_err(|e| anyhow!("Docker error: {e}"))?;
342
343 if running {
344 let status_args = commands::StatusArgs {};
345 return commands::cmd_status(&status_args, host_name.as_deref(), quiet, verbose).await;
346 }
347
348 eprintln!("{} Service is not running.", style("Note:").yellow());
349
350 let confirmed = Confirm::new()
351 .with_prompt("Start the service now?")
352 .default(true)
353 .interact()?;
354
355 if confirmed {
356 let start_args = commands::StartArgs {
357 port: None,
358 open: false,
359 no_daemon: false,
360 pull_sandbox_image: false,
361 cached_rebuild_sandbox_image: false,
362 full_rebuild_sandbox_image: false,
363 ignore_version: false,
364 no_update_check: false,
365 mounts: Vec::new(),
366 no_mounts: false,
367 };
368 commands::cmd_start(&start_args, host_name.as_deref(), quiet, verbose).await?;
369 let status_args = commands::StatusArgs {};
370 return commands::cmd_status(&status_args, host_name.as_deref(), quiet, verbose).await;
371 }
372
373 print_help_hint();
374 Ok(())
375}
376
377fn print_help_hint() {
378 println!(
379 "{} {}",
380 style("opencode-cloud").cyan().bold(),
381 style(get_version()).dim()
382 );
383 println!();
384 println!("Run {} for available commands.", style("--help").green());
385}
386
387#[allow(dead_code)]
393fn acquire_singleton_lock() -> Result<InstanceLock, SingletonError> {
394 let pid_path = config::paths::get_data_dir()
395 .ok_or(SingletonError::InvalidPath)?
396 .join("opencode-cloud.pid");
397
398 InstanceLock::acquire(pid_path)
399}
400
401#[allow(dead_code)]
403fn display_singleton_error(err: &SingletonError) {
404 match err {
405 SingletonError::AlreadyRunning(pid) => {
406 eprintln!(
407 "{} Another instance is already running",
408 style("Error:").red().bold()
409 );
410 eprintln!();
411 eprintln!(" Process ID: {}", style(pid).yellow());
412 eprintln!();
413 eprintln!(
414 " {} Stop the existing instance first:",
415 style("Tip:").cyan()
416 );
417 eprintln!(" {} stop", style("opencode-cloud").green());
418 eprintln!();
419 eprintln!(
420 " {} If the process is stuck, kill it manually:",
421 style("Tip:").cyan()
422 );
423 eprintln!(" {} {}", style("kill").green(), pid);
424 }
425 SingletonError::CreateDirFailed(msg) => {
426 eprintln!(
427 "{} Failed to create data directory",
428 style("Error:").red().bold()
429 );
430 eprintln!();
431 eprintln!(" {msg}");
432 eprintln!();
433 if let Some(data_dir) = config::paths::get_data_dir() {
434 eprintln!(" {} Check permissions for:", style("Tip:").cyan());
435 eprintln!(" {}", style(data_dir.display()).yellow());
436 }
437 }
438 SingletonError::LockFailed(msg) => {
439 eprintln!("{} Failed to acquire lock", style("Error:").red().bold());
440 eprintln!();
441 eprintln!(" {msg}");
442 }
443 SingletonError::InvalidPath => {
444 eprintln!(
445 "{} Could not determine lock file path",
446 style("Error:").red().bold()
447 );
448 eprintln!();
449 eprintln!(
450 " {} Ensure XDG_DATA_HOME or HOME is set.",
451 style("Tip:").cyan()
452 );
453 }
454 }
455}