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}