1use async_trait::async_trait;
23use clap::Args;
24
25use zad::error::{Result, ZadError};
26use zad::secrets::Scope;
27use zad::service::lifecycle::{
28 self, CreateOpts, CreateOutcome, DeleteOpts, DeleteOutcome, DisableOpts, DisableOutcome,
29 EnableOpts, EnableOutcome, ShowOpts, ShowOutcome,
30};
31
32use crate::cli::DialoguerExt;
33
34pub use lifecycle::{
39 LifecycleService, ProjectBlock, ScopeBlock, SecretRef, ServiceStatusOutput, StatusBlock,
40 StatusCheck, leak, status_for,
41};
42
43#[async_trait]
52pub trait CliLifecycle: LifecycleService {
53 type CreateArgs: Args + CreateArgsLike + Send + Sync;
57
58 async fn resolve(
63 args: &Self::CreateArgs,
64 non_interactive: bool,
65 ) -> Result<(Self::Cfg, Self::Secrets)>;
66}
67
68#[derive(Debug, Args)]
74pub struct CreateArgsBase {
75 #[arg(long)]
78 pub local: bool,
79
80 #[arg(long)]
82 pub force: bool,
83
84 #[arg(long)]
86 pub non_interactive: bool,
87
88 #[arg(long)]
90 pub no_validate: bool,
91
92 #[arg(long)]
94 pub no_browser: bool,
95
96 #[arg(long)]
98 pub json: bool,
99}
100
101pub trait CreateArgsLike {
104 fn base(&self) -> &CreateArgsBase;
105}
106
107#[derive(Debug, Args)]
109pub struct BotTokenArgs {
110 #[arg(long, conflicts_with = "bot_token_env")]
111 pub bot_token: Option<String>,
112 #[arg(long, conflicts_with = "bot_token")]
113 pub bot_token_env: Option<String>,
114}
115
116#[derive(Debug, Args)]
118pub struct ScopesArg {
119 #[arg(long, value_delimiter = ',')]
121 pub scopes: Option<Vec<String>>,
122}
123
124#[derive(Debug, Args)]
125pub struct EnableArgs {
126 #[arg(long)]
127 pub force: bool,
128 #[arg(long)]
129 pub non_interactive: bool,
130 #[arg(long)]
131 pub json: bool,
132}
133
134#[derive(Debug, Args)]
135pub struct DisableArgs {
136 #[arg(long)]
137 pub force: bool,
138 #[arg(long)]
139 pub json: bool,
140}
141
142#[derive(Debug, Args)]
143pub struct ShowArgs {
144 #[arg(long)]
145 pub json: bool,
146}
147
148#[derive(Debug, Args)]
149pub struct StatusArgs {
150 #[arg(long)]
151 pub json: bool,
152}
153
154#[derive(Debug, Args)]
155pub struct DeleteArgs {
156 #[arg(long)]
157 pub local: bool,
158 #[arg(long)]
159 pub force: bool,
160 #[arg(long)]
161 pub json: bool,
162}
163
164#[derive(Debug, serde::Serialize)]
170struct CreateEnvelope<'a> {
171 command: String,
172 scope: &'static str,
173 config_path: String,
174 #[serde(flatten)]
175 service: serde_json::Value,
176 scopes: Vec<String>,
177 secrets: &'a [SecretRef],
178 #[serde(skip_serializing_if = "Option::is_none")]
179 authenticated_as: Option<&'a str>,
180 #[serde(skip_serializing_if = "Option::is_none")]
181 hint: Option<&'a str>,
182}
183
184#[derive(Debug, serde::Serialize)]
185struct EnableEnvelope {
186 command: String,
187 project_config: String,
188 credentials_path: String,
189 credentials_scope: &'static str,
190}
191
192#[derive(Debug, serde::Serialize)]
193struct DisableEnvelope {
194 command: String,
195 project_config: String,
196 was_enabled: bool,
197}
198
199#[derive(Debug, serde::Serialize)]
200struct ShowEnvelope<'a> {
201 command: String,
202 service: &'static str,
203 #[serde(skip_serializing_if = "Option::is_none")]
204 effective: Option<&'static str>,
205 global: &'a ScopeBlock,
206 local: &'a ScopeBlock,
207 project: &'a ProjectBlock,
208}
209
210#[derive(Debug, serde::Serialize)]
211struct DeleteEnvelope<'a> {
212 command: String,
213 scope: &'static str,
214 config_path: String,
215 config_removed: bool,
216 secrets: &'a [SecretRef],
217 project_still_references: bool,
218}
219
220pub async fn run_create<T: CliLifecycle>(args: T::CreateArgs) -> Result<()> {
225 let base = args.base();
226 let (config_path, scope_label, scope_machine, keychain_scope): (_, _, _, Scope<'_>) =
227 if base.local {
228 let slug = zad::config::path::project_slug()?;
229 let p = zad::config::path::project_service_config_path_for(&slug, T::NAME)?;
230 (
231 p,
232 "local (project-scoped)".to_string(),
233 "local",
234 Scope::Project(leak(slug)),
235 )
236 } else {
237 (
238 zad::config::path::global_service_config_path(T::NAME)?,
239 "global".to_string(),
240 "global",
241 Scope::Global,
242 )
243 };
244
245 let (cfg, mut creds) = T::resolve(&args, base.non_interactive).await?;
246
247 let validate = !base.no_validate;
248 let opts = CreateOpts {
249 scope_label: scope_machine,
250 scope: keychain_scope,
251 config_path: config_path.clone(),
252 force: base.force,
253 validate,
254 };
255 let outcome: CreateOutcome = lifecycle::create::<T>(&cfg, &mut creds, opts).await?;
256
257 if validate
258 && let Some(name) = outcome.authenticated_as.as_deref()
259 && !base.json
260 {
261 println!(" ✓ authenticated as `{name}`");
262 }
263
264 if base.json {
265 let env = CreateEnvelope {
266 command: format!("service.create.{}", T::NAME),
267 scope: scope_machine,
268 config_path: outcome.config_path.display().to_string(),
269 service: T::cfg_json(&cfg),
270 scopes: T::scopes_of(&cfg).to_vec(),
271 secrets: &outcome.secrets,
272 authenticated_as: outcome.authenticated_as.as_deref(),
273 hint: outcome.hint.as_deref(),
274 };
275 println!("{}", serde_json::to_string_pretty(&env).unwrap());
276 } else {
277 let lines = T::cfg_human(&cfg);
278 let scopes = T::scopes_of(&cfg);
279 let width = label_width(&lines, scopes, &outcome.secrets);
280 println!();
281 println!("{} credentials created ({scope_label}).", T::DISPLAY);
282 let config_label = "config";
283 let config_value = outcome.config_path.display().to_string();
284 println!(" {config_label:width$} : {config_value}");
285 for (label, value) in &lines {
286 println!(" {label:width$} : {value}");
287 }
288 let scopes_label = "scopes";
289 let scopes_value = if scopes.is_empty() {
290 "(none)".to_string()
291 } else {
292 scopes.join(", ")
293 };
294 println!(" {scopes_label:width$} : {scopes_value}");
295 for s in &outcome.secrets {
296 let label = s.label;
297 let account = &s.account;
298 println!(" {label:width$} : OS keychain (service=\"zad\", account=\"{account}\")");
299 }
300 println!();
301 println!(
302 "Next: run `zad service enable {}` in each project that should use {}.",
303 T::NAME,
304 T::DISPLAY
305 );
306 if let Some(url) = outcome.hint.as_deref() {
307 println!();
308 println!(" open: {url}");
309 }
310 }
311
312 if let Some(url) = outcome.hint.as_deref()
313 && !base.no_browser
314 && !base.non_interactive
315 {
316 let _ = open::that(url);
317 }
318
319 Ok(())
320}
321
322pub fn run_enable<T: LifecycleService>(args: EnableArgs) -> Result<()> {
327 let outcome: EnableOutcome = lifecycle::enable::<T>(EnableOpts { force: args.force })?;
328 if args.json {
329 let env = EnableEnvelope {
330 command: format!("service.enable.{}", T::NAME),
331 project_config: outcome.project_config.display().to_string(),
332 credentials_path: outcome.credentials_path.display().to_string(),
333 credentials_scope: outcome.credentials_scope,
334 };
335 println!("{}", serde_json::to_string_pretty(&env).unwrap());
336 } else {
337 println!("{} service enabled for this project.", T::DISPLAY);
338 println!(" project config : {}", outcome.project_config.display());
339 println!(
340 " credentials : {} ({})",
341 outcome.credentials_path.display(),
342 outcome.credentials_scope
343 );
344 }
345 Ok(())
346}
347
348pub fn run_disable<T: LifecycleService>(args: DisableArgs) -> Result<()> {
353 let outcome: DisableOutcome = lifecycle::disable::<T>(DisableOpts { force: args.force })?;
354 if args.json {
355 let env = DisableEnvelope {
356 command: format!("service.disable.{}", T::NAME),
357 project_config: outcome.project_config.display().to_string(),
358 was_enabled: outcome.was_enabled,
359 };
360 println!("{}", serde_json::to_string_pretty(&env).unwrap());
361 } else if outcome.was_enabled {
362 println!("{} service disabled for this project.", T::DISPLAY);
363 println!(" project config : {}", outcome.project_config.display());
364 } else {
365 println!(
366 "{} service was not enabled for this project (nothing to do).",
367 T::DISPLAY
368 );
369 println!(" project config : {}", outcome.project_config.display());
370 }
371 Ok(())
372}
373
374pub fn run_show<T: LifecycleService>(args: ShowArgs) -> Result<()> {
379 let outcome: ShowOutcome = lifecycle::show::<T>(ShowOpts)?;
380
381 if args.json {
382 let env = ShowEnvelope {
383 command: format!("service.show.{}", T::NAME),
384 service: T::NAME,
385 effective: outcome.effective,
386 global: &outcome.global,
387 local: &outcome.local,
388 project: &outcome.project,
389 };
390 println!("{}", serde_json::to_string_pretty(&env).unwrap());
391 return Ok(());
392 }
393
394 println!("Service: {}", T::NAME);
395 println!();
396 println!("## Credentials");
397 if let Some(label) = outcome.effective {
398 println!(" effective : {label}");
399 } else {
400 println!(
401 " effective : (none — run `zad service create {}`)",
402 T::NAME
403 );
404 }
405
406 print_scope_block::<T>("global", &outcome.global);
407 print_scope_block::<T>("local", &outcome.local);
408
409 println!();
410 println!("## Project");
411 if outcome.project.enabled {
412 println!(" enabled : yes");
413 } else {
414 println!(" enabled : no");
415 }
416 println!(" config : {}", outcome.project.config);
417 Ok(())
418}
419
420fn print_scope_block<T: LifecycleService>(label: &str, block: &ScopeBlock) {
421 println!();
422 println!(" [{label}] {}", block.path);
423 if !block.configured {
424 println!(" status : not configured");
425 return;
426 }
427 let scopes_owned: Vec<String> = block.scopes.clone().unwrap_or_default();
428 let width = label_width(&block.human_lines, &scopes_owned, &block.secrets);
429 for (lbl, value) in &block.human_lines {
430 println!(" {lbl:width$} : {value}");
431 }
432 let scopes_label = "scopes";
433 let scopes_value = if scopes_owned.is_empty() {
434 "(none)".to_string()
435 } else {
436 scopes_owned.join(", ")
437 };
438 println!(" {scopes_label:width$} : {scopes_value}");
439 for s in &block.secrets {
440 let lbl = s.label;
441 let state = if s.present { "stored" } else { "missing" };
442 let account = &s.account;
443 println!(" {lbl:width$} : {state} (service=\"zad\", account=\"{account}\")");
444 }
445 let _ = T::NAME; }
447
448pub async fn run_status<T: LifecycleService>(args: StatusArgs) -> Result<()> {
456 let mut out = lifecycle::status_for::<T>().await?;
457 out.command = Some(format!("service.status.{}", T::NAME));
458 if args.json {
459 println!("{}", serde_json::to_string_pretty(&out).unwrap());
460 } else {
461 print_status_human(&out);
462 }
463 if !out.ok {
464 std::process::exit(1);
465 }
466 Ok(())
467}
468
469pub(crate) fn print_status_human(out: &ServiceStatusOutput) {
470 println!("Service: {}", out.service);
471 println!();
472 println!("## Credentials");
473 match out.effective {
474 Some(label) => println!(" effective : {label}"),
475 None => println!(
476 " effective : (none — run `zad service create {}`)",
477 out.service
478 ),
479 }
480 println!(" overall : {}", if out.ok { "ok" } else { "FAILED" });
481
482 print_status_scope("global", &out.global);
483 print_status_scope("local", &out.local);
484
485 println!();
486 println!("## Project");
487 println!(
488 " enabled : {}",
489 if out.project.enabled { "yes" } else { "no" }
490 );
491 println!(" config : {}", out.project.config);
492}
493
494fn print_status_scope(label: &str, block: &StatusBlock) {
495 println!();
496 println!(" [{label}] {}", block.path);
497 if !block.configured {
498 println!(" status : not configured");
499 return;
500 }
501 println!(
502 " credentials : {}",
503 if block.credentials_present {
504 "present"
505 } else {
506 "missing"
507 }
508 );
509 match &block.check {
510 None => println!(" check : (not the effective scope)"),
511 Some(c) if c.ok => {
512 let name = c.authenticated_as.as_deref().unwrap_or("(unknown)");
513 println!(" check : ok (authenticated as `{name}`)");
514 }
515 Some(c) => {
516 let err = c.error.as_deref().unwrap_or("(no error message)");
517 println!(" check : FAILED ({err})");
518 }
519 }
520}
521
522pub fn run_delete<T: LifecycleService>(args: DeleteArgs) -> Result<()> {
527 let (config_path, scope_label, scope_machine, keychain_scope): (_, _, _, Scope<'_>) =
528 if args.local {
529 let slug = zad::config::path::project_slug()?;
530 let p = zad::config::path::project_service_config_path_for(&slug, T::NAME)?;
531 (
532 p,
533 "local (project-scoped)".to_string(),
534 "local",
535 Scope::Project(leak(slug)),
536 )
537 } else {
538 (
539 zad::config::path::global_service_config_path(T::NAME)?,
540 "global".to_string(),
541 "global",
542 Scope::Global,
543 )
544 };
545
546 let outcome: DeleteOutcome = lifecycle::delete::<T>(DeleteOpts {
547 scope_label: scope_machine,
548 scope: keychain_scope,
549 config_path: config_path.clone(),
550 force: args.force,
551 })?;
552
553 if args.json {
554 let env = DeleteEnvelope {
555 command: format!("service.delete.{}", T::NAME),
556 scope: scope_machine,
557 config_path: outcome.config_path.display().to_string(),
558 config_removed: outcome.config_removed,
559 secrets: &outcome.secrets,
560 project_still_references: outcome.project_still_references,
561 };
562 println!("{}", serde_json::to_string_pretty(&env).unwrap());
563 return Ok(());
564 }
565
566 println!("{} credentials deleted ({scope_label}).", T::DISPLAY);
567 println!(
568 " config : {} ({})",
569 outcome.config_path.display(),
570 if outcome.config_removed {
571 "removed"
572 } else {
573 "not present"
574 }
575 );
576 for s in &outcome.secrets {
577 println!(" {} : OS keychain entry `{}` cleared", s.label, s.account);
578 }
579
580 if outcome.project_still_references {
581 let project_path = zad::config::path::project_config_path()?;
582 println!();
583 println!(
584 "warning: this project still references the {} service ({}).",
585 T::NAME,
586 project_path.display()
587 );
588 println!(
589 " Run `zad service disable {}` to remove the `[service.{}]` entry.",
590 T::NAME,
591 T::NAME
592 );
593 }
594
595 Ok(())
596}
597
598pub fn resolve_bot_token(
603 flag: Option<&str>,
604 env_flag: Option<&str>,
605 non_interactive: bool,
606 display: &str,
607) -> Result<String> {
608 if let Some(env) = env_flag {
609 return std::env::var(env).map_err(|_| ZadError::MissingEnv(env.to_string()));
610 }
611 if let Some(v) = flag {
612 return Ok(v.to_string());
613 }
614 if non_interactive {
615 return Err(ZadError::MissingRequired("--bot-token or --bot-token-env"));
616 }
617 let v = dialoguer::Password::with_theme(&dialoguer::theme::ColorfulTheme::default())
618 .with_prompt(format!("{display} bot token"))
619 .interact()
620 .into_zad()?;
621 Ok(v)
622}
623
624pub fn resolve_scopes(
625 flag: Option<&[String]>,
626 default_scopes: &[&'static str],
627 all_scopes: &[&'static str],
628 non_interactive: bool,
629) -> Result<Vec<String>> {
630 if let Some(list) = flag {
631 let cleaned: Vec<String> = list
632 .iter()
633 .map(|s| s.trim().to_string())
634 .filter(|s| !s.is_empty())
635 .collect();
636 for s in &cleaned {
637 if !all_scopes.contains(&s.as_str()) {
638 return Err(ZadError::Invalid(format!("unknown scope: {s}")));
639 }
640 }
641 return Ok(cleaned);
642 }
643 if non_interactive {
644 return Ok(default_scopes.iter().map(|s| s.to_string()).collect());
645 }
646 let defaults: Vec<bool> = all_scopes
647 .iter()
648 .map(|s| default_scopes.contains(s))
649 .collect();
650 let picks = dialoguer::MultiSelect::with_theme(&dialoguer::theme::ColorfulTheme::default())
651 .with_prompt("Scopes (space to toggle, enter to confirm)")
652 .items(all_scopes)
653 .defaults(&defaults)
654 .interact()
655 .into_zad()?;
656 Ok(picks
657 .into_iter()
658 .map(|i| all_scopes[i].to_string())
659 .collect())
660}
661
662fn label_width(
667 cfg_lines: &[(&'static str, String)],
668 scopes: &[String],
669 secrets: &[SecretRef],
670) -> usize {
671 let mut w = "scopes".len();
672 for (l, _) in cfg_lines {
673 w = w.max(l.len());
674 }
675 for s in secrets {
676 w = w.max(s.label.len());
677 }
678 let _ = scopes;
679 w
680}