1mod commands;
6mod output;
7pub mod wizard;
8
9use anyhow::Result;
10use clap::{Parser, Subcommand};
11use console::style;
12use opencode_cloud_core::{
13 DockerClient, InstanceLock, SingletonError, config, get_version, load_config, load_hosts,
14 save_config,
15};
16
17#[derive(Parser)]
19#[command(name = "opencode-cloud")]
20#[command(version = env!("CARGO_PKG_VERSION"))]
21#[command(about = "Manage your opencode cloud service", long_about = None)]
22#[command(after_help = get_banner())]
23struct Cli {
24 #[command(subcommand)]
25 command: Option<Commands>,
26
27 #[arg(short, long, global = true, action = clap::ArgAction::Count)]
29 verbose: u8,
30
31 #[arg(short, long, global = true)]
33 quiet: bool,
34
35 #[arg(long, global = true)]
37 no_color: bool,
38
39 #[arg(long, global = true)]
41 host: Option<String>,
42}
43
44#[derive(Subcommand)]
45enum Commands {
46 Start(commands::StartArgs),
48 Stop(commands::StopArgs),
50 Restart(commands::RestartArgs),
52 Status(commands::StatusArgs),
54 Logs(commands::LogsArgs),
56 Install(commands::InstallArgs),
58 Uninstall(commands::UninstallArgs),
60 Config(commands::ConfigArgs),
62 Setup(commands::SetupArgs),
64 User(commands::UserArgs),
66 Update(commands::UpdateArgs),
68 Cockpit(commands::CockpitArgs),
70 Host(commands::HostArgs),
72}
73
74fn get_banner() -> &'static str {
76 r#"
77 ___ _ __ ___ _ __ ___ ___ __| | ___
78 / _ \| '_ \ / _ \ '_ \ / __/ _ \ / _` |/ _ \
79| (_) | |_) | __/ | | | (_| (_) | (_| | __/
80 \___/| .__/ \___|_| |_|\___\___/ \__,_|\___|
81 |_| cloud
82"#
83}
84
85pub async fn resolve_docker_client(
94 maybe_host: Option<&str>,
95) -> anyhow::Result<(DockerClient, Option<String>)> {
96 let hosts = load_hosts().unwrap_or_default();
97
98 let target_host = maybe_host
100 .map(String::from)
101 .or_else(|| hosts.default_host.clone());
102
103 match target_host {
104 Some(name) if name != "local" && !name.is_empty() => {
105 let host_config = hosts.get_host(&name).ok_or_else(|| {
107 anyhow::anyhow!(
108 "Host '{name}' not found. Run 'occ host list' to see available hosts."
109 )
110 })?;
111
112 let client = DockerClient::connect_remote(host_config, &name).await?;
113 Ok((client, Some(name)))
114 }
115 _ => {
116 let client = DockerClient::new()?;
118 Ok((client, None))
119 }
120 }
121}
122
123pub fn format_host_message(host_name: Option<&str>, message: &str) -> String {
128 match host_name {
129 Some(name) => format!("[{}] {}", style(name).cyan(), message),
130 None => message.to_string(),
131 }
132}
133
134pub fn run() -> Result<()> {
135 tracing_subscriber::fmt::init();
137
138 let cli = Cli::parse();
139
140 if cli.no_color {
142 console::set_colors_enabled(false);
143 }
144
145 let config_path = config::paths::get_config_path()
147 .ok_or_else(|| anyhow::anyhow!("Could not determine config path"))?;
148
149 let config = match load_config() {
150 Ok(config) => {
151 if cli.verbose > 0 {
153 eprintln!(
154 "{} Config loaded from: {}",
155 style("[info]").cyan(),
156 config_path.display()
157 );
158 }
159 config
160 }
161 Err(e) => {
162 eprintln!("{} Configuration error", style("Error:").red().bold());
164 eprintln!();
165 eprintln!(" {e}");
166 eprintln!();
167 eprintln!(" Config file: {}", style(config_path.display()).yellow());
168 eprintln!();
169 eprintln!(
170 " {} Check the config file for syntax errors or unknown fields.",
171 style("Tip:").cyan()
172 );
173 eprintln!(
174 " {} See schemas/config.example.jsonc for valid configuration.",
175 style("Tip:").cyan()
176 );
177 std::process::exit(1);
178 }
179 };
180
181 if cli.verbose > 0 {
183 let data_dir = config::paths::get_data_dir()
184 .map(|p| p.display().to_string())
185 .unwrap_or_else(|| "unknown".to_string());
186 eprintln!(
187 "{} Config: {}",
188 style("[info]").cyan(),
189 config_path.display()
190 );
191 eprintln!("{} Data: {}", style("[info]").cyan(), data_dir);
192 }
193
194 let target_host = cli.host.clone();
196
197 let needs_wizard = !config.has_required_auth()
199 && !matches!(
200 cli.command,
201 Some(Commands::Setup(_)) | Some(Commands::Config(_))
202 );
203
204 if needs_wizard {
205 eprintln!(
206 "{} First-time setup required. Running wizard...",
207 style("Note:").cyan()
208 );
209 eprintln!();
210 let rt = tokio::runtime::Runtime::new()?;
211 let new_config = rt.block_on(wizard::run_wizard(Some(&config)))?;
212 save_config(&new_config)?;
213 eprintln!();
214 eprintln!(
215 "{} Setup complete! Run your command again, or use 'occ start' to begin.",
216 style("Success:").green().bold()
217 );
218 return Ok(());
219 }
220
221 match cli.command {
222 Some(Commands::Start(args)) => {
223 let rt = tokio::runtime::Runtime::new()?;
224 rt.block_on(commands::cmd_start(
225 &args,
226 target_host.as_deref(),
227 cli.quiet,
228 cli.verbose,
229 ))
230 }
231 Some(Commands::Stop(args)) => {
232 let rt = tokio::runtime::Runtime::new()?;
233 rt.block_on(commands::cmd_stop(&args, target_host.as_deref(), cli.quiet))
234 }
235 Some(Commands::Restart(args)) => {
236 let rt = tokio::runtime::Runtime::new()?;
237 rt.block_on(commands::cmd_restart(
238 &args,
239 target_host.as_deref(),
240 cli.quiet,
241 cli.verbose,
242 ))
243 }
244 Some(Commands::Status(args)) => {
245 let rt = tokio::runtime::Runtime::new()?;
246 rt.block_on(commands::cmd_status(
247 &args,
248 target_host.as_deref(),
249 cli.quiet,
250 cli.verbose,
251 ))
252 }
253 Some(Commands::Logs(args)) => {
254 let rt = tokio::runtime::Runtime::new()?;
255 rt.block_on(commands::cmd_logs(&args, target_host.as_deref(), cli.quiet))
256 }
257 Some(Commands::Install(args)) => {
258 let rt = tokio::runtime::Runtime::new()?;
259 rt.block_on(commands::cmd_install(&args, cli.quiet, cli.verbose))
260 }
261 Some(Commands::Uninstall(args)) => {
262 let rt = tokio::runtime::Runtime::new()?;
263 rt.block_on(commands::cmd_uninstall(&args, cli.quiet, cli.verbose))
264 }
265 Some(Commands::Config(cmd)) => commands::cmd_config(cmd, &config, cli.quiet),
266 Some(Commands::Setup(args)) => {
267 let rt = tokio::runtime::Runtime::new()?;
268 rt.block_on(commands::cmd_setup(&args, cli.quiet))
269 }
270 Some(Commands::User(args)) => {
271 let rt = tokio::runtime::Runtime::new()?;
272 rt.block_on(commands::cmd_user(
273 &args,
274 target_host.as_deref(),
275 cli.quiet,
276 cli.verbose,
277 ))
278 }
279 Some(Commands::Update(args)) => {
280 let rt = tokio::runtime::Runtime::new()?;
281 rt.block_on(commands::cmd_update(
282 &args,
283 target_host.as_deref(),
284 cli.quiet,
285 cli.verbose,
286 ))
287 }
288 Some(Commands::Cockpit(args)) => {
289 let rt = tokio::runtime::Runtime::new()?;
290 rt.block_on(commands::cmd_cockpit(
291 &args,
292 target_host.as_deref(),
293 cli.quiet,
294 ))
295 }
296 Some(Commands::Host(args)) => {
297 let rt = tokio::runtime::Runtime::new()?;
298 rt.block_on(commands::cmd_host(&args, cli.quiet, cli.verbose))
299 }
300 None => {
301 if !cli.quiet {
303 println!(
304 "{} {}",
305 style("opencode-cloud").cyan().bold(),
306 style(get_version()).dim()
307 );
308 println!();
309 println!("Run {} for available commands.", style("--help").green());
310 }
311 Ok(())
312 }
313 }
314}
315
316#[allow(dead_code)]
322fn acquire_singleton_lock() -> Result<InstanceLock, SingletonError> {
323 let pid_path = config::paths::get_data_dir()
324 .ok_or(SingletonError::InvalidPath)?
325 .join("opencode-cloud.pid");
326
327 InstanceLock::acquire(pid_path)
328}
329
330#[allow(dead_code)]
332fn display_singleton_error(err: &SingletonError) {
333 match err {
334 SingletonError::AlreadyRunning(pid) => {
335 eprintln!(
336 "{} Another instance is already running",
337 style("Error:").red().bold()
338 );
339 eprintln!();
340 eprintln!(" Process ID: {}", style(pid).yellow());
341 eprintln!();
342 eprintln!(
343 " {} Stop the existing instance first:",
344 style("Tip:").cyan()
345 );
346 eprintln!(" {} stop", style("opencode-cloud").green());
347 eprintln!();
348 eprintln!(
349 " {} If the process is stuck, kill it manually:",
350 style("Tip:").cyan()
351 );
352 eprintln!(" {} {}", style("kill").green(), pid);
353 }
354 SingletonError::CreateDirFailed(msg) => {
355 eprintln!(
356 "{} Failed to create data directory",
357 style("Error:").red().bold()
358 );
359 eprintln!();
360 eprintln!(" {msg}");
361 eprintln!();
362 if let Some(data_dir) = config::paths::get_data_dir() {
363 eprintln!(" {} Check permissions for:", style("Tip:").cyan());
364 eprintln!(" {}", style(data_dir.display()).yellow());
365 }
366 }
367 SingletonError::LockFailed(msg) => {
368 eprintln!("{} Failed to acquire lock", style("Error:").red().bold());
369 eprintln!();
370 eprintln!(" {msg}");
371 }
372 SingletonError::InvalidPath => {
373 eprintln!(
374 "{} Could not determine lock file path",
375 style("Error:").red().bold()
376 );
377 eprintln!();
378 eprintln!(
379 " {} Ensure XDG_DATA_HOME or HOME is set.",
380 style("Tip:").cyan()
381 );
382 }
383 }
384}