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 Mount(commands::MountArgs),
68 Update(commands::UpdateArgs),
70 Cockpit(commands::CockpitArgs),
72 Host(commands::HostArgs),
74}
75
76fn get_banner() -> &'static str {
78 r#"
79 ___ _ __ ___ _ __ ___ ___ __| | ___
80 / _ \| '_ \ / _ \ '_ \ / __/ _ \ / _` |/ _ \
81| (_) | |_) | __/ | | | (_| (_) | (_| | __/
82 \___/| .__/ \___|_| |_|\___\___/ \__,_|\___|
83 |_| cloud
84"#
85}
86
87pub async fn resolve_docker_client(
96 maybe_host: Option<&str>,
97) -> anyhow::Result<(DockerClient, Option<String>)> {
98 let hosts = load_hosts().unwrap_or_default();
99
100 let target_host = maybe_host
102 .map(String::from)
103 .or_else(|| hosts.default_host.clone());
104
105 match target_host {
106 Some(name) if name != "local" && !name.is_empty() => {
107 let host_config = hosts.get_host(&name).ok_or_else(|| {
109 anyhow::anyhow!(
110 "Host '{name}' not found. Run 'occ host list' to see available hosts."
111 )
112 })?;
113
114 let client = DockerClient::connect_remote(host_config, &name).await?;
115 Ok((client, Some(name)))
116 }
117 _ => {
118 let client = DockerClient::new()?;
120 Ok((client, None))
121 }
122 }
123}
124
125pub fn format_host_message(host_name: Option<&str>, message: &str) -> String {
130 match host_name {
131 Some(name) => format!("[{}] {}", style(name).cyan(), message),
132 None => message.to_string(),
133 }
134}
135
136pub fn run() -> Result<()> {
137 tracing_subscriber::fmt::init();
139
140 let cli = Cli::parse();
141
142 if cli.no_color {
144 console::set_colors_enabled(false);
145 }
146
147 eprintln!(
148 "{} This tool is still a work in progress and may be unstable. Follow updates at https://github.com/pRizz/opencode-cloud. Stability will be announced at some point.",
149 style("Warning:").yellow().bold()
150 );
151 eprintln!();
152
153 let config_path = config::paths::get_config_path()
155 .ok_or_else(|| anyhow::anyhow!("Could not determine config path"))?;
156
157 let config = match load_config() {
158 Ok(config) => {
159 if cli.verbose > 0 {
161 eprintln!(
162 "{} Config loaded from: {}",
163 style("[info]").cyan(),
164 config_path.display()
165 );
166 }
167 config
168 }
169 Err(e) => {
170 eprintln!("{} Configuration error", style("Error:").red().bold());
172 eprintln!();
173 eprintln!(" {e}");
174 eprintln!();
175 eprintln!(" Config file: {}", style(config_path.display()).yellow());
176 eprintln!();
177 eprintln!(
178 " {} Check the config file for syntax errors or unknown fields.",
179 style("Tip:").cyan()
180 );
181 eprintln!(
182 " {} See schemas/config.example.jsonc for valid configuration.",
183 style("Tip:").cyan()
184 );
185 std::process::exit(1);
186 }
187 };
188
189 if cli.verbose > 0 {
191 let data_dir = config::paths::get_data_dir()
192 .map(|p| p.display().to_string())
193 .unwrap_or_else(|| "unknown".to_string());
194 eprintln!(
195 "{} Config: {}",
196 style("[info]").cyan(),
197 config_path.display()
198 );
199 eprintln!("{} Data: {}", style("[info]").cyan(), data_dir);
200 }
201
202 let target_host = cli.host.clone();
204
205 let needs_wizard = !config.has_required_auth()
207 && !matches!(
208 cli.command,
209 Some(Commands::Setup(_)) | Some(Commands::Config(_))
210 );
211
212 if needs_wizard {
213 eprintln!(
214 "{} First-time setup required. Running wizard...",
215 style("Note:").cyan()
216 );
217 eprintln!();
218 let rt = tokio::runtime::Runtime::new()?;
219 let new_config = rt.block_on(wizard::run_wizard(Some(&config)))?;
220 save_config(&new_config)?;
221 eprintln!();
222 eprintln!(
223 "{} Setup complete! Run your command again, or use 'occ start' to begin.",
224 style("Success:").green().bold()
225 );
226 return Ok(());
227 }
228
229 match cli.command {
230 Some(Commands::Start(args)) => {
231 let rt = tokio::runtime::Runtime::new()?;
232 rt.block_on(commands::cmd_start(
233 &args,
234 target_host.as_deref(),
235 cli.quiet,
236 cli.verbose,
237 ))
238 }
239 Some(Commands::Stop(args)) => {
240 let rt = tokio::runtime::Runtime::new()?;
241 rt.block_on(commands::cmd_stop(&args, target_host.as_deref(), cli.quiet))
242 }
243 Some(Commands::Restart(args)) => {
244 let rt = tokio::runtime::Runtime::new()?;
245 rt.block_on(commands::cmd_restart(
246 &args,
247 target_host.as_deref(),
248 cli.quiet,
249 cli.verbose,
250 ))
251 }
252 Some(Commands::Status(args)) => {
253 let rt = tokio::runtime::Runtime::new()?;
254 rt.block_on(commands::cmd_status(
255 &args,
256 target_host.as_deref(),
257 cli.quiet,
258 cli.verbose,
259 ))
260 }
261 Some(Commands::Logs(args)) => {
262 let rt = tokio::runtime::Runtime::new()?;
263 rt.block_on(commands::cmd_logs(&args, target_host.as_deref(), cli.quiet))
264 }
265 Some(Commands::Install(args)) => {
266 let rt = tokio::runtime::Runtime::new()?;
267 rt.block_on(commands::cmd_install(&args, cli.quiet, cli.verbose))
268 }
269 Some(Commands::Uninstall(args)) => {
270 let rt = tokio::runtime::Runtime::new()?;
271 rt.block_on(commands::cmd_uninstall(&args, cli.quiet, cli.verbose))
272 }
273 Some(Commands::Config(cmd)) => commands::cmd_config(cmd, &config, cli.quiet),
274 Some(Commands::Setup(args)) => {
275 let rt = tokio::runtime::Runtime::new()?;
276 rt.block_on(commands::cmd_setup(&args, cli.quiet))
277 }
278 Some(Commands::User(args)) => {
279 let rt = tokio::runtime::Runtime::new()?;
280 rt.block_on(commands::cmd_user(
281 &args,
282 target_host.as_deref(),
283 cli.quiet,
284 cli.verbose,
285 ))
286 }
287 Some(Commands::Mount(args)) => {
288 let rt = tokio::runtime::Runtime::new()?;
289 rt.block_on(commands::cmd_mount(&args, cli.quiet, cli.verbose))
290 }
291 Some(Commands::Update(args)) => {
292 let rt = tokio::runtime::Runtime::new()?;
293 rt.block_on(commands::cmd_update(
294 &args,
295 target_host.as_deref(),
296 cli.quiet,
297 cli.verbose,
298 ))
299 }
300 Some(Commands::Cockpit(args)) => {
301 let rt = tokio::runtime::Runtime::new()?;
302 rt.block_on(commands::cmd_cockpit(
303 &args,
304 target_host.as_deref(),
305 cli.quiet,
306 ))
307 }
308 Some(Commands::Host(args)) => {
309 let rt = tokio::runtime::Runtime::new()?;
310 rt.block_on(commands::cmd_host(&args, cli.quiet, cli.verbose))
311 }
312 None => {
313 if !cli.quiet {
315 println!(
316 "{} {}",
317 style("opencode-cloud").cyan().bold(),
318 style(get_version()).dim()
319 );
320 println!();
321 println!("Run {} for available commands.", style("--help").green());
322 }
323 Ok(())
324 }
325 }
326}
327
328#[allow(dead_code)]
334fn acquire_singleton_lock() -> Result<InstanceLock, SingletonError> {
335 let pid_path = config::paths::get_data_dir()
336 .ok_or(SingletonError::InvalidPath)?
337 .join("opencode-cloud.pid");
338
339 InstanceLock::acquire(pid_path)
340}
341
342#[allow(dead_code)]
344fn display_singleton_error(err: &SingletonError) {
345 match err {
346 SingletonError::AlreadyRunning(pid) => {
347 eprintln!(
348 "{} Another instance is already running",
349 style("Error:").red().bold()
350 );
351 eprintln!();
352 eprintln!(" Process ID: {}", style(pid).yellow());
353 eprintln!();
354 eprintln!(
355 " {} Stop the existing instance first:",
356 style("Tip:").cyan()
357 );
358 eprintln!(" {} stop", style("opencode-cloud").green());
359 eprintln!();
360 eprintln!(
361 " {} If the process is stuck, kill it manually:",
362 style("Tip:").cyan()
363 );
364 eprintln!(" {} {}", style("kill").green(), pid);
365 }
366 SingletonError::CreateDirFailed(msg) => {
367 eprintln!(
368 "{} Failed to create data directory",
369 style("Error:").red().bold()
370 );
371 eprintln!();
372 eprintln!(" {msg}");
373 eprintln!();
374 if let Some(data_dir) = config::paths::get_data_dir() {
375 eprintln!(" {} Check permissions for:", style("Tip:").cyan());
376 eprintln!(" {}", style(data_dir.display()).yellow());
377 }
378 }
379 SingletonError::LockFailed(msg) => {
380 eprintln!("{} Failed to acquire lock", style("Error:").red().bold());
381 eprintln!();
382 eprintln!(" {msg}");
383 }
384 SingletonError::InvalidPath => {
385 eprintln!(
386 "{} Could not determine lock file path",
387 style("Error:").red().bold()
388 );
389 eprintln!();
390 eprintln!(
391 " {} Ensure XDG_DATA_HOME or HOME is set.",
392 style("Tip:").cyan()
393 );
394 }
395 }
396}