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, load_hosts,
17 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 if !config_exists {
161 eprintln!(
162 "{} First-time setup required. Running wizard...",
163 style("Note:").cyan()
164 );
165 eprintln!();
166 let rt = tokio::runtime::Runtime::new()?;
167 let new_config = rt.block_on(wizard::run_wizard(None))?;
168 save_config(&new_config)?;
169 eprintln!();
170 eprintln!(
171 "{} Setup complete! Run your command again, or use 'occ start' to begin.",
172 style("Success:").green().bold()
173 );
174 return Ok(());
175 }
176
177 let config = match load_config() {
179 Ok(config) => {
180 if cli.verbose > 0 {
182 eprintln!(
183 "{} Config loaded from: {}",
184 style("[info]").cyan(),
185 config_path.display()
186 );
187 }
188 config
189 }
190 Err(e) => {
191 eprintln!("{} Configuration error", style("Error:").red().bold());
193 eprintln!();
194 eprintln!(" {e}");
195 eprintln!();
196 eprintln!(" Config file: {}", style(config_path.display()).yellow());
197 eprintln!();
198 eprintln!(
199 " {} Check the config file for syntax errors or unknown fields.",
200 style("Tip:").cyan()
201 );
202 eprintln!(
203 " {} See schemas/config.example.jsonc for valid configuration.",
204 style("Tip:").cyan()
205 );
206 std::process::exit(1);
207 }
208 };
209
210 if cli.verbose > 0 {
212 let data_dir = config::paths::get_data_dir()
213 .map(|p| p.display().to_string())
214 .unwrap_or_else(|| "unknown".to_string());
215 eprintln!(
216 "{} Config: {}",
217 style("[info]").cyan(),
218 config_path.display()
219 );
220 eprintln!("{} Data: {}", style("[info]").cyan(), data_dir);
221 }
222
223 let target_host = cli.host.clone();
225
226 match cli.command {
227 Some(Commands::Start(args)) => {
228 let rt = tokio::runtime::Runtime::new()?;
229 rt.block_on(commands::cmd_start(
230 &args,
231 target_host.as_deref(),
232 cli.quiet,
233 cli.verbose,
234 ))
235 }
236 Some(Commands::Stop(args)) => {
237 let rt = tokio::runtime::Runtime::new()?;
238 rt.block_on(commands::cmd_stop(&args, target_host.as_deref(), cli.quiet))
239 }
240 Some(Commands::Restart(args)) => {
241 let rt = tokio::runtime::Runtime::new()?;
242 rt.block_on(commands::cmd_restart(
243 &args,
244 target_host.as_deref(),
245 cli.quiet,
246 cli.verbose,
247 ))
248 }
249 Some(Commands::Status(args)) => {
250 let rt = tokio::runtime::Runtime::new()?;
251 rt.block_on(commands::cmd_status(
252 &args,
253 target_host.as_deref(),
254 cli.quiet,
255 cli.verbose,
256 ))
257 }
258 Some(Commands::Logs(args)) => {
259 let rt = tokio::runtime::Runtime::new()?;
260 rt.block_on(commands::cmd_logs(&args, target_host.as_deref(), cli.quiet))
261 }
262 Some(Commands::Install(args)) => {
263 let rt = tokio::runtime::Runtime::new()?;
264 rt.block_on(commands::cmd_install(&args, cli.quiet, cli.verbose))
265 }
266 Some(Commands::Uninstall(args)) => {
267 let rt = tokio::runtime::Runtime::new()?;
268 rt.block_on(commands::cmd_uninstall(&args, cli.quiet, cli.verbose))
269 }
270 Some(Commands::Config(cmd)) => commands::cmd_config(cmd, &config, cli.quiet),
271 Some(Commands::Setup(args)) => {
272 let rt = tokio::runtime::Runtime::new()?;
273 rt.block_on(commands::cmd_setup(&args, cli.quiet))
274 }
275 Some(Commands::User(args)) => {
276 let rt = tokio::runtime::Runtime::new()?;
277 rt.block_on(commands::cmd_user(
278 &args,
279 target_host.as_deref(),
280 cli.quiet,
281 cli.verbose,
282 ))
283 }
284 Some(Commands::Mount(args)) => {
285 let rt = tokio::runtime::Runtime::new()?;
286 rt.block_on(commands::cmd_mount(&args, cli.quiet, cli.verbose))
287 }
288 Some(Commands::Update(args)) => {
289 let rt = tokio::runtime::Runtime::new()?;
290 rt.block_on(commands::cmd_update(
291 &args,
292 target_host.as_deref(),
293 cli.quiet,
294 cli.verbose,
295 ))
296 }
297 Some(Commands::Cockpit(args)) => {
298 let rt = tokio::runtime::Runtime::new()?;
299 rt.block_on(commands::cmd_cockpit(
300 &args,
301 target_host.as_deref(),
302 cli.quiet,
303 ))
304 }
305 Some(Commands::Host(args)) => {
306 let rt = tokio::runtime::Runtime::new()?;
307 rt.block_on(commands::cmd_host(&args, cli.quiet, cli.verbose))
308 }
309 None => {
310 let rt = tokio::runtime::Runtime::new()?;
311 rt.block_on(handle_no_command(
312 target_host.as_deref(),
313 cli.quiet,
314 cli.verbose,
315 ))
316 }
317 }
318}
319
320async fn handle_no_command(target_host: Option<&str>, quiet: bool, verbose: u8) -> Result<()> {
321 if quiet {
322 return Ok(());
323 }
324
325 let (client, host_name) = resolve_docker_client(target_host).await?;
326 client
327 .verify_connection()
328 .await
329 .map_err(|e| anyhow!("Docker connection error: {e}"))?;
330
331 let running = opencode_cloud_core::docker::container_is_running(
332 &client,
333 opencode_cloud_core::docker::CONTAINER_NAME,
334 )
335 .await
336 .map_err(|e| anyhow!("Docker error: {e}"))?;
337
338 if running {
339 let status_args = commands::StatusArgs {};
340 return commands::cmd_status(&status_args, host_name.as_deref(), quiet, verbose).await;
341 }
342
343 eprintln!("{} Service is not running.", style("Note:").yellow());
344
345 let confirmed = Confirm::new()
346 .with_prompt("Start the service now?")
347 .default(true)
348 .interact()?;
349
350 if confirmed {
351 let start_args = commands::StartArgs {
352 port: None,
353 open: false,
354 no_daemon: false,
355 pull_sandbox_image: false,
356 cached_rebuild_sandbox_image: false,
357 full_rebuild_sandbox_image: false,
358 ignore_version: false,
359 no_update_check: false,
360 mounts: Vec::new(),
361 no_mounts: false,
362 };
363 commands::cmd_start(&start_args, host_name.as_deref(), quiet, verbose).await?;
364 let status_args = commands::StatusArgs {};
365 return commands::cmd_status(&status_args, host_name.as_deref(), quiet, verbose).await;
366 }
367
368 print_help_hint();
369 Ok(())
370}
371
372fn print_help_hint() {
373 println!(
374 "{} {}",
375 style("opencode-cloud").cyan().bold(),
376 style(get_version()).dim()
377 );
378 println!();
379 println!("Run {} for available commands.", style("--help").green());
380}
381
382#[allow(dead_code)]
388fn acquire_singleton_lock() -> Result<InstanceLock, SingletonError> {
389 let pid_path = config::paths::get_data_dir()
390 .ok_or(SingletonError::InvalidPath)?
391 .join("opencode-cloud.pid");
392
393 InstanceLock::acquire(pid_path)
394}
395
396#[allow(dead_code)]
398fn display_singleton_error(err: &SingletonError) {
399 match err {
400 SingletonError::AlreadyRunning(pid) => {
401 eprintln!(
402 "{} Another instance is already running",
403 style("Error:").red().bold()
404 );
405 eprintln!();
406 eprintln!(" Process ID: {}", style(pid).yellow());
407 eprintln!();
408 eprintln!(
409 " {} Stop the existing instance first:",
410 style("Tip:").cyan()
411 );
412 eprintln!(" {} stop", style("opencode-cloud").green());
413 eprintln!();
414 eprintln!(
415 " {} If the process is stuck, kill it manually:",
416 style("Tip:").cyan()
417 );
418 eprintln!(" {} {}", style("kill").green(), pid);
419 }
420 SingletonError::CreateDirFailed(msg) => {
421 eprintln!(
422 "{} Failed to create data directory",
423 style("Error:").red().bold()
424 );
425 eprintln!();
426 eprintln!(" {msg}");
427 eprintln!();
428 if let Some(data_dir) = config::paths::get_data_dir() {
429 eprintln!(" {} Check permissions for:", style("Tip:").cyan());
430 eprintln!(" {}", style(data_dir.display()).yellow());
431 }
432 }
433 SingletonError::LockFailed(msg) => {
434 eprintln!("{} Failed to acquire lock", style("Error:").red().bold());
435 eprintln!();
436 eprintln!(" {msg}");
437 }
438 SingletonError::InvalidPath => {
439 eprintln!(
440 "{} Could not determine lock file path",
441 style("Error:").red().bold()
442 );
443 eprintln!();
444 eprintln!(
445 " {} Ensure XDG_DATA_HOME or HOME is set.",
446 style("Tip:").cyan()
447 );
448 }
449 }
450}