Skip to main content

zad_cli/cli/
lifecycle.rs

1//! CLI shell over the library's typed lifecycle drivers.
2//!
3//! The typed core — the `LifecycleService` trait, driver functions
4//! (`create`, `enable`, `disable`, `show`, `delete`, `status_for`),
5//! and the `*Outcome` types — lives in
6//! [`zad::service::lifecycle`]. This module adds the CLI-only layer:
7//!
8//! 1. **`CliLifecycle`** — extends `LifecycleService` with a
9//!    `clap::Args`-derived `CreateArgs` associated type and an
10//!    interactive `resolve` step that turns CLI flags into
11//!    `(Cfg, Secrets)`.
12//! 2. **clap arg structs** — `CreateArgsBase`, `BotTokenArgs`,
13//!    `ScopesArg`, `EnableArgs`, `DisableArgs`, `ShowArgs`,
14//!    `StatusArgs`, `DeleteArgs`. Per-service `CreateArgs` flatten
15//!    these in.
16//! 3. **`run_*` driver wrappers** that prompt where needed, call
17//!    into the library drivers, and render the typed `*Outcome`
18//!    values to stdout in human or JSON form.
19//! 4. **`resolve_bot_token` / `resolve_scopes`** — the only places
20//!    `dialoguer` is invoked from inside the lifecycle layer.
21
22use 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
34// Re-exports so existing per-service adapters and tests keep their
35// `use crate::cli::lifecycle::{SecretRef, ServiceStatusOutput, …}`
36// paths working unchanged. The library module is the source of truth;
37// these are thin facades.
38pub use lifecycle::{
39    LifecycleService, ProjectBlock, ScopeBlock, SecretRef, ServiceStatusOutput, StatusBlock,
40    StatusCheck, leak, status_for,
41};
42
43// ---------------------------------------------------------------------------
44// CLI extension trait
45// ---------------------------------------------------------------------------
46
47/// CLI-side extension of the library's `LifecycleService`. Adds the
48/// clap-derived `CreateArgs` shape and the interactive `resolve` step.
49/// Implementors live in `cli/service_<name>.rs` alongside their
50/// `LifecycleService` impl.
51#[async_trait]
52pub trait CliLifecycle: LifecycleService {
53    /// Per-service `zad service create <name>` flag struct. Must
54    /// embed [`CreateArgsBase`] via `#[command(flatten)]` and expose
55    /// it through [`CreateArgsLike::base`].
56    type CreateArgs: Args + CreateArgsLike + Send + Sync;
57
58    /// Build `(Cfg, Secrets)` from CLI args. Interactive mode prompts
59    /// for any `Option<_>` fields that arrived empty; non-interactive
60    /// mode returns [`ZadError::MissingRequired`] for anything still
61    /// missing.
62    async fn resolve(
63        args: &Self::CreateArgs,
64        non_interactive: bool,
65    ) -> Result<(Self::Cfg, Self::Secrets)>;
66}
67
68// ---------------------------------------------------------------------------
69// Shared clap arg structs
70// ---------------------------------------------------------------------------
71
72/// Flags every `zad service create <name>` accepts.
73#[derive(Debug, Args)]
74pub struct CreateArgsBase {
75    /// Write credentials to this project's private service directory
76    /// instead of the shared global location.
77    #[arg(long)]
78    pub local: bool,
79
80    /// Overwrite any existing configuration at the chosen scope.
81    #[arg(long)]
82    pub force: bool,
83
84    /// Fail instead of prompting for any missing value.
85    #[arg(long)]
86    pub non_interactive: bool,
87
88    /// Skip the provider-side token validation step.
89    #[arg(long)]
90    pub no_validate: bool,
91
92    /// Don't open URLs in the system browser.
93    #[arg(long)]
94    pub no_browser: bool,
95
96    /// Emit machine-readable JSON instead of human-readable text.
97    #[arg(long)]
98    pub json: bool,
99}
100
101/// Lets the driver read [`CreateArgsBase`] out of a service-specific
102/// wrapper.
103pub trait CreateArgsLike {
104    fn base(&self) -> &CreateArgsBase;
105}
106
107/// Drop-in clap flags for a single bot-token credential.
108#[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/// Drop-in clap flag for the common "comma-separated scopes" pattern.
117#[derive(Debug, Args)]
118pub struct ScopesArg {
119    /// Capabilities to enable (comma-separated).
120    #[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// ---------------------------------------------------------------------------
165// JSON envelopes — wrap the library's typed outcomes in a `command`
166// field for the existing CLI contract (e.g. `service.create.discord`).
167// ---------------------------------------------------------------------------
168
169#[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
220// ---------------------------------------------------------------------------
221// CLI driver: create
222// ---------------------------------------------------------------------------
223
224pub 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
322// ---------------------------------------------------------------------------
323// CLI driver: enable
324// ---------------------------------------------------------------------------
325
326pub 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
348// ---------------------------------------------------------------------------
349// CLI driver: disable
350// ---------------------------------------------------------------------------
351
352pub 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
374// ---------------------------------------------------------------------------
375// CLI driver: show
376// ---------------------------------------------------------------------------
377
378pub 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; // keep the type bound live for parity
446}
447
448// ---------------------------------------------------------------------------
449// CLI driver: status
450// ---------------------------------------------------------------------------
451
452/// Run `zad service status --service <svc>` for service `T`. Emits
453/// JSON or human output, then exits the process with code 1 if the
454/// effective scope failed its live ping.
455pub 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
522// ---------------------------------------------------------------------------
523// CLI driver: delete
524// ---------------------------------------------------------------------------
525
526pub 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
598// ---------------------------------------------------------------------------
599// Shared prompt helpers (the only place this module touches dialoguer)
600// ---------------------------------------------------------------------------
601
602pub 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
662// ---------------------------------------------------------------------------
663// Rendering helpers
664// ---------------------------------------------------------------------------
665
666fn 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}