1mod commands;
6mod constants;
7mod output;
8mod passwords;
9pub mod wizard;
10
11use anyhow::Result;
12use clap::{Parser, Subcommand};
13use console::style;
14use opencode_cloud_core::{
15 DockerClient, InstanceLock, SingletonError, config, get_version, load_config, load_hosts,
16 save_config,
17};
18
19#[derive(Parser)]
21#[command(name = "opencode-cloud")]
22#[command(version = env!("CARGO_PKG_VERSION"))]
23#[command(about = "Manage your opencode cloud service", long_about = None)]
24#[command(after_help = get_banner())]
25struct Cli {
26 #[command(subcommand)]
27 command: Option<Commands>,
28
29 #[arg(short, long, global = true, action = clap::ArgAction::Count)]
31 verbose: u8,
32
33 #[arg(short, long, global = true)]
35 quiet: bool,
36
37 #[arg(long, global = true)]
39 no_color: bool,
40
41 #[arg(long, global = true)]
43 host: Option<String>,
44}
45
46#[derive(Subcommand)]
47enum Commands {
48 Start(commands::StartArgs),
50 Stop(commands::StopArgs),
52 Restart(commands::RestartArgs),
54 Status(commands::StatusArgs),
56 Logs(commands::LogsArgs),
58 Install(commands::InstallArgs),
60 Uninstall(commands::UninstallArgs),
62 Config(commands::ConfigArgs),
64 Setup(commands::SetupArgs),
66 User(commands::UserArgs),
68 Mount(commands::MountArgs),
70 Update(commands::UpdateArgs),
72 Cockpit(commands::CockpitArgs),
74 Host(commands::HostArgs),
76}
77
78fn get_banner() -> &'static str {
80 r#"
81 ___ _ __ ___ _ __ ___ ___ __| | ___
82 / _ \| '_ \ / _ \ '_ \ / __/ _ \ / _` |/ _ \
83| (_) | |_) | __/ | | | (_| (_) | (_| | __/
84 \___/| .__/ \___|_| |_|\___\___/ \__,_|\___|
85 |_| cloud
86"#
87}
88
89pub async fn resolve_docker_client(
98 maybe_host: Option<&str>,
99) -> anyhow::Result<(DockerClient, Option<String>)> {
100 let hosts = load_hosts().unwrap_or_default();
101
102 let target_host = maybe_host
104 .map(String::from)
105 .or_else(|| hosts.default_host.clone());
106
107 match target_host {
108 Some(name) if name != "local" && !name.is_empty() => {
109 let host_config = hosts.get_host(&name).ok_or_else(|| {
111 anyhow::anyhow!(
112 "Host '{name}' not found. Run 'occ host list' to see available hosts."
113 )
114 })?;
115
116 let client = DockerClient::connect_remote(host_config, &name).await?;
117 Ok((client, Some(name)))
118 }
119 _ => {
120 let client = DockerClient::new()?;
122 Ok((client, None))
123 }
124 }
125}
126
127pub fn format_host_message(host_name: Option<&str>, message: &str) -> String {
132 match host_name {
133 Some(name) => format!("[{}] {}", style(name).cyan(), message),
134 None => message.to_string(),
135 }
136}
137
138pub fn run() -> Result<()> {
139 tracing_subscriber::fmt::init();
141
142 let cli = Cli::parse();
143
144 if cli.no_color {
146 console::set_colors_enabled(false);
147 }
148
149 eprintln!(
150 "{} 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.",
151 style("Warning:").yellow().bold()
152 );
153 eprintln!();
154
155 let config_path = config::paths::get_config_path()
157 .ok_or_else(|| anyhow::anyhow!("Could not determine config path"))?;
158
159 let config = match load_config() {
160 Ok(config) => {
161 if cli.verbose > 0 {
163 eprintln!(
164 "{} Config loaded from: {}",
165 style("[info]").cyan(),
166 config_path.display()
167 );
168 }
169 config
170 }
171 Err(e) => {
172 eprintln!("{} Configuration error", style("Error:").red().bold());
174 eprintln!();
175 eprintln!(" {e}");
176 eprintln!();
177 eprintln!(" Config file: {}", style(config_path.display()).yellow());
178 eprintln!();
179 eprintln!(
180 " {} Check the config file for syntax errors or unknown fields.",
181 style("Tip:").cyan()
182 );
183 eprintln!(
184 " {} See schemas/config.example.jsonc for valid configuration.",
185 style("Tip:").cyan()
186 );
187 std::process::exit(1);
188 }
189 };
190
191 if cli.verbose > 0 {
193 let data_dir = config::paths::get_data_dir()
194 .map(|p| p.display().to_string())
195 .unwrap_or_else(|| "unknown".to_string());
196 eprintln!(
197 "{} Config: {}",
198 style("[info]").cyan(),
199 config_path.display()
200 );
201 eprintln!("{} Data: {}", style("[info]").cyan(), data_dir);
202 }
203
204 let target_host = cli.host.clone();
206
207 let needs_wizard = !config.has_required_auth()
209 && !matches!(
210 cli.command,
211 Some(Commands::Setup(_)) | Some(Commands::Config(_)) | Some(Commands::User(_))
212 );
213
214 if needs_wizard {
215 eprintln!(
216 "{} First-time setup required. Running wizard...",
217 style("Note:").cyan()
218 );
219 eprintln!();
220 let rt = tokio::runtime::Runtime::new()?;
221 let new_config = rt.block_on(wizard::run_wizard(Some(&config)))?;
222 save_config(&new_config)?;
223 eprintln!();
224 eprintln!(
225 "{} Setup complete! Run your command again, or use 'occ start' to begin.",
226 style("Success:").green().bold()
227 );
228 return Ok(());
229 }
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 if !cli.quiet {
317 println!(
318 "{} {}",
319 style("opencode-cloud").cyan().bold(),
320 style(get_version()).dim()
321 );
322 println!();
323 println!("Run {} for available commands.", style("--help").green());
324 }
325 Ok(())
326 }
327 }
328}
329
330#[allow(dead_code)]
336fn acquire_singleton_lock() -> Result<InstanceLock, SingletonError> {
337 let pid_path = config::paths::get_data_dir()
338 .ok_or(SingletonError::InvalidPath)?
339 .join("opencode-cloud.pid");
340
341 InstanceLock::acquire(pid_path)
342}
343
344#[allow(dead_code)]
346fn display_singleton_error(err: &SingletonError) {
347 match err {
348 SingletonError::AlreadyRunning(pid) => {
349 eprintln!(
350 "{} Another instance is already running",
351 style("Error:").red().bold()
352 );
353 eprintln!();
354 eprintln!(" Process ID: {}", style(pid).yellow());
355 eprintln!();
356 eprintln!(
357 " {} Stop the existing instance first:",
358 style("Tip:").cyan()
359 );
360 eprintln!(" {} stop", style("opencode-cloud").green());
361 eprintln!();
362 eprintln!(
363 " {} If the process is stuck, kill it manually:",
364 style("Tip:").cyan()
365 );
366 eprintln!(" {} {}", style("kill").green(), pid);
367 }
368 SingletonError::CreateDirFailed(msg) => {
369 eprintln!(
370 "{} Failed to create data directory",
371 style("Error:").red().bold()
372 );
373 eprintln!();
374 eprintln!(" {msg}");
375 eprintln!();
376 if let Some(data_dir) = config::paths::get_data_dir() {
377 eprintln!(" {} Check permissions for:", style("Tip:").cyan());
378 eprintln!(" {}", style(data_dir.display()).yellow());
379 }
380 }
381 SingletonError::LockFailed(msg) => {
382 eprintln!("{} Failed to acquire lock", style("Error:").red().bold());
383 eprintln!();
384 eprintln!(" {msg}");
385 }
386 SingletonError::InvalidPath => {
387 eprintln!(
388 "{} Could not determine lock file path",
389 style("Error:").red().bold()
390 );
391 eprintln!();
392 eprintln!(
393 " {} Ensure XDG_DATA_HOME or HOME is set.",
394 style("Tip:").cyan()
395 );
396 }
397 }
398}