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 && !config.allow_unauthenticated_network
210 && !matches!(
211 cli.command,
212 Some(Commands::Setup(_)) | Some(Commands::Config(_)) | Some(Commands::User(_))
213 );
214
215 if needs_wizard {
216 eprintln!(
217 "{} First-time setup required. Running wizard...",
218 style("Note:").cyan()
219 );
220 eprintln!();
221 let rt = tokio::runtime::Runtime::new()?;
222 let new_config = rt.block_on(wizard::run_wizard(Some(&config)))?;
223 save_config(&new_config)?;
224 eprintln!();
225 eprintln!(
226 "{} Setup complete! Run your command again, or use 'occ start' to begin.",
227 style("Success:").green().bold()
228 );
229 return Ok(());
230 }
231
232 match cli.command {
233 Some(Commands::Start(args)) => {
234 let rt = tokio::runtime::Runtime::new()?;
235 rt.block_on(commands::cmd_start(
236 &args,
237 target_host.as_deref(),
238 cli.quiet,
239 cli.verbose,
240 ))
241 }
242 Some(Commands::Stop(args)) => {
243 let rt = tokio::runtime::Runtime::new()?;
244 rt.block_on(commands::cmd_stop(&args, target_host.as_deref(), cli.quiet))
245 }
246 Some(Commands::Restart(args)) => {
247 let rt = tokio::runtime::Runtime::new()?;
248 rt.block_on(commands::cmd_restart(
249 &args,
250 target_host.as_deref(),
251 cli.quiet,
252 cli.verbose,
253 ))
254 }
255 Some(Commands::Status(args)) => {
256 let rt = tokio::runtime::Runtime::new()?;
257 rt.block_on(commands::cmd_status(
258 &args,
259 target_host.as_deref(),
260 cli.quiet,
261 cli.verbose,
262 ))
263 }
264 Some(Commands::Logs(args)) => {
265 let rt = tokio::runtime::Runtime::new()?;
266 rt.block_on(commands::cmd_logs(&args, target_host.as_deref(), cli.quiet))
267 }
268 Some(Commands::Install(args)) => {
269 let rt = tokio::runtime::Runtime::new()?;
270 rt.block_on(commands::cmd_install(&args, cli.quiet, cli.verbose))
271 }
272 Some(Commands::Uninstall(args)) => {
273 let rt = tokio::runtime::Runtime::new()?;
274 rt.block_on(commands::cmd_uninstall(&args, cli.quiet, cli.verbose))
275 }
276 Some(Commands::Config(cmd)) => commands::cmd_config(cmd, &config, cli.quiet),
277 Some(Commands::Setup(args)) => {
278 let rt = tokio::runtime::Runtime::new()?;
279 rt.block_on(commands::cmd_setup(&args, cli.quiet))
280 }
281 Some(Commands::User(args)) => {
282 let rt = tokio::runtime::Runtime::new()?;
283 rt.block_on(commands::cmd_user(
284 &args,
285 target_host.as_deref(),
286 cli.quiet,
287 cli.verbose,
288 ))
289 }
290 Some(Commands::Mount(args)) => {
291 let rt = tokio::runtime::Runtime::new()?;
292 rt.block_on(commands::cmd_mount(&args, cli.quiet, cli.verbose))
293 }
294 Some(Commands::Update(args)) => {
295 let rt = tokio::runtime::Runtime::new()?;
296 rt.block_on(commands::cmd_update(
297 &args,
298 target_host.as_deref(),
299 cli.quiet,
300 cli.verbose,
301 ))
302 }
303 Some(Commands::Cockpit(args)) => {
304 let rt = tokio::runtime::Runtime::new()?;
305 rt.block_on(commands::cmd_cockpit(
306 &args,
307 target_host.as_deref(),
308 cli.quiet,
309 ))
310 }
311 Some(Commands::Host(args)) => {
312 let rt = tokio::runtime::Runtime::new()?;
313 rt.block_on(commands::cmd_host(&args, cli.quiet, cli.verbose))
314 }
315 None => {
316 if !cli.quiet {
318 println!(
319 "{} {}",
320 style("opencode-cloud").cyan().bold(),
321 style(get_version()).dim()
322 );
323 println!();
324 println!("Run {} for available commands.", style("--help").green());
325 }
326 Ok(())
327 }
328 }
329}
330
331#[allow(dead_code)]
337fn acquire_singleton_lock() -> Result<InstanceLock, SingletonError> {
338 let pid_path = config::paths::get_data_dir()
339 .ok_or(SingletonError::InvalidPath)?
340 .join("opencode-cloud.pid");
341
342 InstanceLock::acquire(pid_path)
343}
344
345#[allow(dead_code)]
347fn display_singleton_error(err: &SingletonError) {
348 match err {
349 SingletonError::AlreadyRunning(pid) => {
350 eprintln!(
351 "{} Another instance is already running",
352 style("Error:").red().bold()
353 );
354 eprintln!();
355 eprintln!(" Process ID: {}", style(pid).yellow());
356 eprintln!();
357 eprintln!(
358 " {} Stop the existing instance first:",
359 style("Tip:").cyan()
360 );
361 eprintln!(" {} stop", style("opencode-cloud").green());
362 eprintln!();
363 eprintln!(
364 " {} If the process is stuck, kill it manually:",
365 style("Tip:").cyan()
366 );
367 eprintln!(" {} {}", style("kill").green(), pid);
368 }
369 SingletonError::CreateDirFailed(msg) => {
370 eprintln!(
371 "{} Failed to create data directory",
372 style("Error:").red().bold()
373 );
374 eprintln!();
375 eprintln!(" {msg}");
376 eprintln!();
377 if let Some(data_dir) = config::paths::get_data_dir() {
378 eprintln!(" {} Check permissions for:", style("Tip:").cyan());
379 eprintln!(" {}", style(data_dir.display()).yellow());
380 }
381 }
382 SingletonError::LockFailed(msg) => {
383 eprintln!("{} Failed to acquire lock", style("Error:").red().bold());
384 eprintln!();
385 eprintln!(" {msg}");
386 }
387 SingletonError::InvalidPath => {
388 eprintln!(
389 "{} Could not determine lock file path",
390 style("Error:").red().bold()
391 );
392 eprintln!();
393 eprintln!(
394 " {} Ensure XDG_DATA_HOME or HOME is set.",
395 style("Tip:").cyan()
396 );
397 }
398 }
399}