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