Skip to main content

via/
doctor.rs

1use std::collections::BTreeMap;
2use std::process::Command;
3
4use crate::config::{
5    AuthConfig, CommandConfig, Config, OnePasswordCacheMode, ProviderConfig, RestCommandConfig,
6    ServiceConfig,
7};
8use crate::error::ViaError;
9use crate::providers::ProviderRegistry;
10
11pub fn run(config: &Config, only_service: Option<&str>) -> Result<(), ViaError> {
12    validate_requested_service(config, only_service)?;
13
14    let mut status = DoctorStatus::default();
15    let provider_ready = check_providers(config, &mut status);
16    let providers = ProviderRegistry::from_config(config)?;
17
18    for (service_name, service) in &config.services {
19        if should_check_service(service_name, only_service) {
20            check_service(
21                service_name,
22                service,
23                &provider_ready,
24                &providers,
25                &mut status,
26            )?;
27        }
28    }
29
30    status.into_result()
31}
32
33fn validate_requested_service(config: &Config, only_service: Option<&str>) -> Result<(), ViaError> {
34    if let Some(service_name) = only_service {
35        if !config.services.contains_key(service_name) {
36            return Err(ViaError::UnknownService(service_name.to_owned()));
37        }
38    }
39
40    Ok(())
41}
42
43fn should_check_service(service_name: &str, only_service: Option<&str>) -> bool {
44    only_service.is_none_or(|only| only == service_name)
45}
46
47fn check_service(
48    service_name: &str,
49    service: &ServiceConfig,
50    provider_ready: &BTreeMap<String, bool>,
51    providers: &ProviderRegistry,
52    status: &mut DoctorStatus,
53) -> Result<(), ViaError> {
54    println!("service {service_name}: checking");
55    let service_provider_ready = provider_ready
56        .get(&service.provider)
57        .copied()
58        .unwrap_or(false);
59
60    check_service_secrets(
61        service_name,
62        service,
63        service_provider_ready,
64        providers,
65        status,
66    )?;
67    check_service_commands(
68        service_name,
69        service,
70        service_provider_ready,
71        providers,
72        status,
73    )
74}
75
76fn check_service_secrets(
77    service_name: &str,
78    service: &ServiceConfig,
79    service_provider_ready: bool,
80    providers: &ProviderRegistry,
81    status: &mut DoctorStatus,
82) -> Result<(), ViaError> {
83    if service.secrets.is_empty() {
84        println!("  secrets: none configured");
85        return Ok(());
86    }
87
88    if !service_provider_ready {
89        status.fail();
90        println!(
91            "  secrets: skipped because provider `{}` is not ready",
92            service.provider
93        );
94        print_agent_guidance(
95            "Ask the user to complete secret provider setup, then rerun `via config doctor`.",
96        );
97        return Ok(());
98    }
99
100    let provider = providers.get(&service.provider)?;
101    for (secret_name, reference) in &service.secrets {
102        match provider.resolve(reference) {
103            Ok(_) => println!("  secret {secret_name}: readable by via"),
104            Err(error) => {
105                status.fail();
106                print_secret_failure(service_name, secret_name, &error);
107            }
108        }
109    }
110
111    Ok(())
112}
113
114fn check_service_commands(
115    service_name: &str,
116    service: &ServiceConfig,
117    service_provider_ready: bool,
118    providers: &ProviderRegistry,
119    status: &mut DoctorStatus,
120) -> Result<(), ViaError> {
121    for (command_name, command) in &service.commands {
122        check_service_command(
123            service_name,
124            command_name,
125            service,
126            command,
127            service_provider_ready,
128            providers,
129            status,
130        )?;
131    }
132
133    Ok(())
134}
135
136fn check_service_command(
137    service_name: &str,
138    command_name: &str,
139    service: &ServiceConfig,
140    command: &CommandConfig,
141    service_provider_ready: bool,
142    providers: &ProviderRegistry,
143    status: &mut DoctorStatus,
144) -> Result<(), ViaError> {
145    match command {
146        CommandConfig::Rest(rest) => {
147            println!("  capability {command_name}: rest");
148            if service_provider_ready {
149                check_rest_auth(service_name, command_name, service, rest, providers, status)?;
150            }
151        }
152        CommandConfig::Delegated(delegated) => {
153            check_delegated_command(command_name, &delegated.program, &delegated.check, status);
154        }
155    }
156
157    Ok(())
158}
159
160fn check_delegated_command(
161    command_name: &str,
162    program: &str,
163    check: &[String],
164    status: &mut DoctorStatus,
165) {
166    match check_program(program, check) {
167        Ok(()) => println!("  capability {command_name}: delegated {program}"),
168        Err(error) => {
169            status.fail();
170            print_delegated_failure(command_name, program, &error);
171        }
172    }
173}
174
175fn check_rest_auth(
176    service_name: &str,
177    command_name: &str,
178    service: &ServiceConfig,
179    rest: &RestCommandConfig,
180    providers: &ProviderRegistry,
181    status: &mut DoctorStatus,
182) -> Result<(), ViaError> {
183    let Some(auth @ AuthConfig::GitHubApp { .. }) = &rest.auth else {
184        return Ok(());
185    };
186
187    let provider = providers.get(&service.provider)?;
188
189    match resolve_github_app_doctor_secrets(service_name, service, provider, auth).and_then(
190        |(credential, private_key)| {
191            crate::auth::github_app::validate_credential_bundle(
192                credential.expose(),
193                private_key.as_deref(),
194            )
195        },
196    ) {
197        Ok(()) => println!("  auth {command_name}: GitHub App credential bundle valid"),
198        Err(error) => {
199            status.fail();
200            println!("  auth {command_name}: GitHub App credential bundle invalid");
201            println!("  reason: {error}");
202            print_human_setup(&[
203                "Edit the configured 1Password metadata field for this GitHub App credential bundle.",
204                "The metadata field must contain valid JSON with `type`, numeric `app_id`, and `installation_id`.",
205                "The private key should be a separate 1Password file attachment referenced by the `private_key` auth setting.",
206                "If using the legacy single-field bundle, replace raw PEM line breaks with escaped `\\n` newlines inside `private_key`.",
207                "Do not paste the real private key into an online validator.",
208                &format!(
209                    "Rerun `via config doctor {service_name}` after updating the 1Password field."
210                ),
211            ]);
212            print_agent_guidance(
213                "Ask the user to fix the GitHub App credential bundle in 1Password; do not ask for the private key value.",
214            );
215        }
216    }
217
218    Ok(())
219}
220
221fn resolve_github_app_doctor_secrets(
222    service_name: &str,
223    service: &ServiceConfig,
224    provider: &dyn crate::providers::SecretProvider,
225    auth: &AuthConfig,
226) -> Result<(crate::secrets::SecretValue, Option<String>), ViaError> {
227    let AuthConfig::GitHubApp {
228        secret,
229        credential,
230        private_key,
231    } = auth
232    else {
233        unreachable!("caller only passes github_app auth");
234    };
235
236    match (secret, credential, private_key) {
237        (Some(secret), None, None) => {
238            let credential = resolve_doctor_secret(service_name, service, provider, secret)?;
239            Ok((credential, None))
240        }
241        (None, Some(credential), Some(private_key)) => {
242            let credential = resolve_doctor_secret(service_name, service, provider, credential)?;
243            let private_key = resolve_doctor_secret(service_name, service, provider, private_key)?;
244            Ok((credential, Some(private_key.expose().to_owned())))
245        }
246        _ => Err(ViaError::InvalidConfig(
247            "github_app auth must set either `secret` or both `credential` and `private_key`"
248                .to_owned(),
249        )),
250    }
251}
252
253fn resolve_doctor_secret(
254    service_name: &str,
255    service: &ServiceConfig,
256    provider: &dyn crate::providers::SecretProvider,
257    secret: &str,
258) -> Result<crate::secrets::SecretValue, ViaError> {
259    let reference = service
260        .secrets
261        .get(secret)
262        .ok_or_else(|| ViaError::UnknownSecret {
263            service: service_name.to_owned(),
264            secret: secret.to_owned(),
265        })?;
266    provider.resolve(reference)
267}
268
269#[derive(Default)]
270struct DoctorStatus {
271    failed: bool,
272}
273
274impl DoctorStatus {
275    fn fail(&mut self) {
276        self.failed = true;
277    }
278
279    fn into_result(self) -> Result<(), ViaError> {
280        if self.failed {
281            Err(ViaError::DoctorFailed)
282        } else {
283            Ok(())
284        }
285    }
286}
287
288fn check_program(program: &str, check: &[String]) -> Result<(), ViaError> {
289    let args = if check.is_empty() {
290        vec!["--version".to_owned()]
291    } else {
292        check.to_owned()
293    };
294
295    run_command(program, &args).map(|_| ())
296}
297
298fn check_providers(config: &Config, status: &mut DoctorStatus) -> BTreeMap<String, bool> {
299    let mut ready = BTreeMap::new();
300
301    for (provider_name, provider) in &config.providers {
302        let provider_ready = match provider {
303            ProviderConfig::OnePassword {
304                account,
305                cache,
306                cache_ttl_seconds,
307            } => check_onepassword_provider(
308                provider_name,
309                account.as_deref(),
310                *cache,
311                *cache_ttl_seconds,
312                status,
313            ),
314        };
315        ready.insert(provider_name.clone(), provider_ready);
316    }
317
318    ready
319}
320
321fn check_onepassword_provider(
322    provider_name: &str,
323    account: Option<&str>,
324    cache: OnePasswordCacheMode,
325    cache_ttl_seconds: u64,
326    status: &mut DoctorStatus,
327) -> bool {
328    println!("provider {provider_name} (1Password): checking");
329    print_onepassword_cache(cache, cache_ttl_seconds);
330
331    if !check_onepassword_cli_installed(status) {
332        return false;
333    }
334    if !check_onepassword_account(account, status) {
335        return false;
336    }
337
338    check_onepassword_authentication(account, status)
339}
340
341fn print_onepassword_cache(cache: OnePasswordCacheMode, cache_ttl_seconds: u64) {
342    match cache {
343        OnePasswordCacheMode::Daemon => {
344            println!("  cache: daemon enabled (ttl {cache_ttl_seconds}s)")
345        }
346        OnePasswordCacheMode::Off => println!("  cache: off"),
347    }
348}
349
350fn check_onepassword_cli_installed(status: &mut DoctorStatus) -> bool {
351    match run_command("op", &["--version".to_owned()]) {
352        Ok(output) => {
353            print_onepassword_version(&output.stdout);
354            true
355        }
356        Err(error) => {
357            status.fail();
358            print_onepassword_cli_failure(&error);
359            false
360        }
361    }
362}
363
364fn print_onepassword_version(version: &str) {
365    if version.is_empty() {
366        println!("  1Password CLI: installed");
367    } else {
368        println!("  1Password CLI: installed ({version})");
369    }
370}
371
372fn print_onepassword_cli_failure(error: &ViaError) {
373    println!("  1Password CLI: not ready");
374    print_error_hint(error);
375    print_human_setup(&[
376        "Install the 1Password CLI.",
377        "macOS/Homebrew: `brew install --cask 1password-cli`.",
378        "Windows/winget: `winget install -e --id AgileBits.1Password.CLI`.",
379        "Linux: follow the official APT/YUM/Alpine/Nix/manual steps at https://developer.1password.com/docs/cli/get-started/.",
380        "Verify the CLI is available with `op --version`.",
381        "Install the 1Password desktop app if it is not already installed.",
382        "Open and unlock the 1Password desktop app.",
383        "Enable the 1Password CLI integration in the desktop app: Settings > Developer > Integrate with 1Password CLI.",
384        "Rerun `via config doctor` after setup.",
385    ]);
386    print_agent_guidance(
387        "Ask the user to install the secret provider, run `via login`, then rerun `via config doctor`.",
388    );
389}
390
391fn check_onepassword_account(account: Option<&str>, status: &mut DoctorStatus) -> bool {
392    let Some(account) = account else {
393        return true;
394    };
395
396    let args = vec!["account".to_owned(), "get".to_owned(), account.to_owned()];
397    match run_command("op", &args) {
398        Ok(_) => {
399            println!("  account {account}: configured");
400            true
401        }
402        Err(error) => {
403            status.fail();
404            print_onepassword_account_failure(account, &error);
405            false
406        }
407    }
408}
409
410fn print_onepassword_account_failure(account: &str, error: &ViaError) {
411    println!("  account {account}: not ready");
412    print_error_hint(error);
413    print_human_setup(&[
414        "Add this 1Password account to the desktop app or CLI.",
415        "Confirm the provider account in `via.toml` matches a configured account ID or sign-in address.",
416        "Rerun `via config doctor` after the account is available.",
417    ]);
418    print_agent_guidance(
419        "Ask the user to fix the configured 1Password account, then rerun `via config doctor`.",
420    );
421}
422
423fn check_onepassword_authentication(account: Option<&str>, status: &mut DoctorStatus) -> bool {
424    let mut args = vec!["whoami".to_owned()];
425    if let Some(account) = account {
426        args.push("--account".to_owned());
427        args.push(account.to_owned());
428    }
429
430    match run_command("op", &args) {
431        Ok(_) => {
432            println!("  authentication: ready");
433            true
434        }
435        Err(error) => {
436            status.fail();
437            print_onepassword_auth_failure(&error);
438            false
439        }
440    }
441}
442
443fn print_onepassword_auth_failure(error: &ViaError) {
444    println!("  authentication: not ready");
445    print_error_hint(error);
446    print_onepassword_auth_setup(error);
447    print_agent_guidance("Ask the user to run `via login`, then rerun `via config doctor`.");
448}
449
450fn print_onepassword_auth_setup(error: &ViaError) {
451    if is_onepassword_not_signed_in(error) {
452        print_onepassword_signed_out_setup();
453    } else if is_onepassword_account_missing(error) {
454        print_onepassword_missing_account_setup();
455    } else {
456        print_onepassword_desktop_setup();
457    }
458}
459
460fn print_onepassword_signed_out_setup() {
461    print_human_setup(&[
462        "The 1Password CLI can see an account, but it is not signed in.",
463        "Run `via login` from your terminal and choose the account that contains the configured vault.",
464        "Approve the sign-in from the 1Password desktop app if prompted.",
465        "Run `via config doctor` to confirm the CLI session is active.",
466        "If multiple accounts are visible, set `[providers.onepassword] account = \"<account-id-or-sign-in-address>\"` in the via config.",
467        "Rerun `via login` after pinning the account if needed.",
468    ]);
469}
470
471fn print_onepassword_missing_account_setup() {
472    print_human_setup(&[
473        "The 1Password CLI is installed, but it cannot find a signed-in account.",
474        "Open the 1Password desktop app and confirm the account containing the configured vault is added and unlocked.",
475        "Enable the 1Password CLI integration in the desktop app: Settings > Developer > Integrate with 1Password CLI.",
476        "Run `op account list` in your terminal to confirm the account is visible to the CLI.",
477        "Run `via login` after the account is visible.",
478        "If multiple accounts are visible, set `[providers.onepassword] account = \"<account-id-or-sign-in-address>\"` in the via config.",
479        "Rerun `via config doctor` after authentication succeeds.",
480    ]);
481}
482
483fn print_onepassword_desktop_setup() {
484    print_human_setup(&[
485        "Install the 1Password desktop app if it is not already installed.",
486        "macOS/Homebrew: `brew install --cask 1password`.",
487        "Windows/winget: `winget install -e --id AgileBits.1Password`.",
488        "Linux: follow the official desktop app install steps at https://support.1password.com/install-linux/.",
489        "Add your 1Password account to the desktop app.",
490        "Open and unlock the 1Password desktop app.",
491        "Enable the 1Password CLI integration in the desktop app: Settings > Developer > Integrate with 1Password CLI.",
492        "Run `via login` from your terminal.",
493        "Rerun `via config doctor` after authentication succeeds.",
494    ]);
495}
496
497struct CommandOutput {
498    stdout: String,
499}
500
501fn run_command(program: &str, args: &[String]) -> Result<CommandOutput, ViaError> {
502    let output = Command::new(program).args(args).output();
503    match output {
504        Ok(output) if output.status.success() => Ok(CommandOutput {
505            stdout: String::from_utf8_lossy(&output.stdout).trim().to_owned(),
506        }),
507        Ok(output) => Err(ViaError::ExternalCommandFailed {
508            program: program.to_owned(),
509            status: output.status.code(),
510            stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
511        }),
512        Err(source) => Err(ViaError::MissingProgram {
513            program: program.to_owned(),
514            source,
515        }),
516    }
517}
518
519fn print_secret_failure(service_name: &str, secret_name: &str, error: &ViaError) {
520    println!("  secret {secret_name}: not readable by via");
521    print_secret_error_hint(error);
522    print_human_setup(&[
523        &format!(
524            "Confirm the configured 1Password reference for `{service_name}.{secret_name}` exists."
525        ),
526        "Confirm your signed-in account has permission to read it.",
527        "Update `via.toml` with the correct secret reference if needed.",
528        &format!("Rerun `via config doctor {service_name}` after fixing the secret."),
529    ]);
530    print_agent_guidance(
531        "Do not ask for the token value. Ask the user to fix the configured secret reference or 1Password permissions.",
532    );
533}
534
535fn print_secret_error_hint(error: &ViaError) {
536    match error {
537        ViaError::MissingProgram { .. } => {
538            println!("  reason: secret provider command was not found on PATH");
539        }
540        ViaError::ExternalCommandFailed { status, .. } => {
541            println!("  reason: secret provider could not read the configured reference; status {status:?}");
542        }
543        _ => println!("  reason: secret provider could not read the configured reference"),
544    }
545}
546
547fn print_delegated_failure(command_name: &str, program: &str, error: &ViaError) {
548    println!("  capability {command_name}: delegated {program} not ready");
549    print_error_hint(error);
550    print_human_setup(&[
551        &format!("Install `{program}` or make sure it is available on PATH."),
552        "Run `via config doctor` again after the delegated tool is available.",
553    ]);
554    print_agent_guidance(
555        "Ask the user to install or fix the delegated tool, then rerun `via config doctor`.",
556    );
557}
558
559fn print_error_hint(error: &ViaError) {
560    match error {
561        ViaError::MissingProgram { program, .. } => {
562            println!("  reason: `{program}` was not found on PATH");
563        }
564        ViaError::ExternalCommandFailed { status, stderr, .. } => {
565            println!("  reason: command exited with status {status:?}");
566            if !stderr.is_empty() {
567                println!("  detail: {stderr}");
568            }
569        }
570        _ => println!("  reason: {error}"),
571    }
572}
573
574fn is_onepassword_account_missing(error: &ViaError) -> bool {
575    matches!(
576        error,
577        ViaError::ExternalCommandFailed { stderr, .. }
578            if stderr.contains("no account found for filter")
579    )
580}
581
582fn is_onepassword_not_signed_in(error: &ViaError) -> bool {
583    matches!(
584        error,
585        ViaError::ExternalCommandFailed { stderr, .. }
586            if stderr.contains("account is not signed in")
587    )
588}
589
590fn print_human_setup(steps: &[&str]) {
591    println!("  Human setup:");
592    for step in steps {
593        println!("    - {step}");
594    }
595}
596
597fn print_agent_guidance(message: &str) {
598    println!("  Agent guidance:");
599    println!("    - {message}");
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605
606    fn config() -> Config {
607        Config::from_toml_str(
608            r#"
609version = 1
610
611[providers.onepassword]
612type = "1password"
613
614[services.github]
615provider = "onepassword"
616
617[services.github.secrets]
618token = "op://Private/GitHub/token"
619
620[services.github.commands.api]
621mode = "rest"
622base_url = "https://api.github.com"
623"#,
624        )
625        .unwrap()
626    }
627
628    #[test]
629    fn rejects_unknown_service_before_provider_checks() {
630        let error = run(&config(), Some("missing")).unwrap_err();
631
632        assert!(matches!(error, ViaError::UnknownService(service) if service == "missing"));
633    }
634
635    #[test]
636    fn check_program_accepts_successful_check_command() {
637        check_program("sh", &["-c".to_owned(), "exit 0".to_owned()]).unwrap();
638    }
639
640    #[test]
641    fn check_program_reports_failed_check_command() {
642        let error = check_program("sh", &["-c".to_owned(), "exit 9".to_owned()]).unwrap_err();
643
644        assert!(matches!(
645            error,
646            ViaError::ExternalCommandFailed {
647                program,
648                status: Some(9),
649                ..
650            } if program == "sh"
651        ));
652    }
653
654    #[test]
655    fn run_command_captures_stdout_without_newline() {
656        let output = run_command("sh", &["-c".to_owned(), "printf 'ready\\n'".to_owned()]).unwrap();
657
658        assert_eq!(output.stdout, "ready");
659    }
660
661    #[test]
662    fn detects_onepassword_missing_account_error() {
663        let error = ViaError::ExternalCommandFailed {
664            program: "op".to_owned(),
665            status: Some(1),
666            stderr: "[ERROR] no account found for filter".to_owned(),
667        };
668
669        assert!(is_onepassword_account_missing(&error));
670    }
671
672    #[test]
673    fn detects_onepassword_signed_out_error() {
674        let error = ViaError::ExternalCommandFailed {
675            program: "op".to_owned(),
676            status: Some(1),
677            stderr: "[ERROR] account is not signed in".to_owned(),
678        };
679
680        assert!(is_onepassword_not_signed_in(&error));
681    }
682}