1use std::fs;
2use std::io::{self, IsTerminal, Write};
3use std::path::Path;
4
5use crate::cli::ConfigCommand;
6use crate::config::{self, Config};
7use crate::doctor;
8use crate::error::ViaError;
9
10pub fn run(path_override: Option<&Path>, command: ConfigCommand) -> Result<(), ViaError> {
11 let path = config::resolve_path(path_override)?;
12
13 match command {
14 ConfigCommand::Configure => configure(&path),
15 ConfigCommand::Path => {
16 println!("{}", path.display());
17 Ok(())
18 }
19 ConfigCommand::Doctor { service } => {
20 if !path.exists() {
21 print_missing_config(&path);
22 return Err(ViaError::ConfigNotFound(
23 "run `via config` in an interactive terminal to create one".to_owned(),
24 ));
25 }
26
27 let config = Config::load(Some(&path))?;
28 doctor::run(&config, service.as_deref())
29 }
30 }
31}
32
33fn configure(path: &Path) -> Result<(), ViaError> {
34 if path.exists() {
35 println!("via config: {}", path.display());
36 println!("Run `via config doctor` to check providers, secrets, and delegated tools.");
37 return Ok(());
38 }
39
40 if !io::stdin().is_terminal() {
41 print_missing_config(path);
42 return Err(ViaError::ConfigNotFound(
43 "run `via config` in an interactive terminal to create one".to_owned(),
44 ));
45 }
46
47 println!("No via config found.");
48 println!();
49 println!("via can create one at:");
50 println!(" {}", path.display());
51 println!();
52
53 match prompt_choice(
54 "What do you want to configure?",
55 &[
56 "A service with 1Password",
57 "Empty config",
58 "Print config path only",
59 ],
60 1,
61 )? {
62 1 => write_config(path, &build_service_config(prompt_service_setup()?)),
63 2 => write_config(path, empty_config()),
64 3 => {
65 println!("{}", path.display());
66 Ok(())
67 }
68 _ => unreachable!("prompt_choice only returns listed choices"),
69 }
70}
71
72fn write_config(path: &Path, contents: &str) -> Result<(), ViaError> {
73 if let Some(parent) = path.parent() {
74 fs::create_dir_all(parent)?;
75 }
76 fs::write(path, contents)?;
77 println!("created via config: {}", path.display());
78 println!("Run `via config doctor` to check the setup.");
79 Ok(())
80}
81
82struct ServiceSetup {
83 service_name: String,
84 secret_name: String,
85 secret_reference: String,
86 private_key_secret_name: Option<String>,
87 private_key_secret_reference: Option<String>,
88 rest: Option<RestSetup>,
89 delegated: Option<DelegatedSetup>,
90}
91
92struct RestSetup {
93 command_name: String,
94 base_url: String,
95 method_default: String,
96 auth: RestAuthSetup,
97}
98
99enum RestAuthSetup {
100 Bearer,
101 GitHubApp,
102}
103
104struct DelegatedSetup {
105 command_name: String,
106 program: String,
107 env_var: String,
108 check_args: Vec<String>,
109}
110
111fn prompt_service_setup() -> Result<ServiceSetup, ViaError> {
112 let service_name = prompt_required("Service name", None)?;
113 let secret_name = prompt_required("Secret name in via config", Some("token"))?;
114 let secret_reference = prompt_secret_reference()?;
115 let mode = prompt_service_mode()?;
116 let rest = prompt_optional_rest_setup(mode)?;
117 let (private_key_secret_name, private_key_secret_reference) =
118 prompt_optional_private_key(rest.as_ref())?;
119 let delegated = prompt_optional_delegated_setup(mode)?;
120
121 Ok(ServiceSetup {
122 service_name,
123 secret_name,
124 secret_reference,
125 private_key_secret_name,
126 private_key_secret_reference,
127 rest,
128 delegated,
129 })
130}
131
132fn prompt_service_mode() -> Result<usize, ViaError> {
133 prompt_choice(
134 "How should via run this service?",
135 &["REST API", "Trusted CLI", "Both"],
136 1,
137 )
138}
139
140fn prompt_optional_rest_setup(mode: usize) -> Result<Option<RestSetup>, ViaError> {
141 if mode_uses_rest(mode) {
142 Ok(Some(prompt_rest_setup()?))
143 } else {
144 Ok(None)
145 }
146}
147
148fn prompt_optional_private_key(
149 rest: Option<&RestSetup>,
150) -> Result<(Option<String>, Option<String>), ViaError> {
151 if rest.is_some_and(rest_uses_github_app) {
152 prompt_private_key_secret()
153 } else {
154 Ok((None, None))
155 }
156}
157
158fn prompt_optional_delegated_setup(mode: usize) -> Result<Option<DelegatedSetup>, ViaError> {
159 if mode_uses_delegated(mode) {
160 Ok(Some(prompt_delegated_setup()?))
161 } else {
162 Ok(None)
163 }
164}
165
166fn mode_uses_rest(mode: usize) -> bool {
167 mode == 1 || mode == 3
168}
169
170fn mode_uses_delegated(mode: usize) -> bool {
171 mode == 2 || mode == 3
172}
173
174fn rest_uses_github_app(rest: &RestSetup) -> bool {
175 matches!(rest.auth, RestAuthSetup::GitHubApp)
176}
177
178fn prompt_private_key_secret() -> Result<(Option<String>, Option<String>), ViaError> {
179 println!();
180 println!("GitHub App private key");
181 let name = prompt_required("Private key secret name in via config", Some("private_key"))?;
182 let reference = prompt_secret_reference()?;
183 Ok((Some(name), Some(reference)))
184}
185
186fn prompt_secret_reference() -> Result<String, ViaError> {
187 loop {
188 println!("1Password secret reference:");
189 println!(" Example: op://Private/Service/token");
190 let value = prompt_required("Reference", None)?;
191 if value.starts_with("op://") {
192 return Ok(value);
193 }
194 println!("Secret references must start with `op://`.");
195 }
196}
197
198fn prompt_rest_setup() -> Result<RestSetup, ViaError> {
199 println!();
200 println!("REST API capability");
201 let auth = match prompt_choice(
202 "How should REST authenticate?",
203 &["Bearer token", "GitHub App credential bundle"],
204 1,
205 )? {
206 1 => RestAuthSetup::Bearer,
207 2 => RestAuthSetup::GitHubApp,
208 _ => unreachable!("prompt_choice only returns listed choices"),
209 };
210
211 Ok(RestSetup {
212 command_name: prompt_required("Capability name", Some("api"))?,
213 base_url: prompt_required("Base URL", None)?,
214 method_default: prompt_required("Default HTTP method", Some("GET"))?,
215 auth,
216 })
217}
218
219fn prompt_delegated_setup() -> Result<DelegatedSetup, ViaError> {
220 println!();
221 println!("Trusted CLI capability");
222 let program = prompt_required("Program", None)?;
223 let default_command = program.clone();
224 let command_name = prompt_required("Capability name", Some(&default_command))?;
225 let env_var = prompt_required("Environment variable to inject", Some("TOKEN"))?;
226 let check = prompt_required("Check command args", Some("--version"))?;
227
228 Ok(DelegatedSetup {
229 command_name,
230 program,
231 env_var,
232 check_args: split_args(&check),
233 })
234}
235
236fn prompt_choice(prompt: &str, choices: &[&str], default: usize) -> Result<usize, ViaError> {
237 loop {
238 println!("{prompt}");
239 for (index, choice) in choices.iter().enumerate() {
240 println!(" {}. {choice}", index + 1);
241 }
242
243 let raw = prompt_optional(&format!("Choice [{default}]"))?;
244 let choice = if raw.is_empty() {
245 default
246 } else {
247 match raw.parse::<usize>() {
248 Ok(choice) => choice,
249 Err(_) => {
250 println!("Enter a number from 1 to {}.", choices.len());
251 continue;
252 }
253 }
254 };
255
256 if (1..=choices.len()).contains(&choice) {
257 return Ok(choice);
258 }
259 println!("Enter a number from 1 to {}.", choices.len());
260 }
261}
262
263fn prompt_required(prompt: &str, default: Option<&str>) -> Result<String, ViaError> {
264 loop {
265 let label = match default {
266 Some(default) => format!("{prompt} [{default}]"),
267 None => prompt.to_owned(),
268 };
269 let value = prompt_optional(&label)?;
270 if !value.is_empty() {
271 return Ok(value);
272 }
273 if let Some(default) = default {
274 return Ok(default.to_owned());
275 }
276 println!("This value is required.");
277 }
278}
279
280fn prompt_optional(prompt: &str) -> Result<String, ViaError> {
281 print!("{prompt}: ");
282 io::stdout().flush()?;
283
284 let mut value = String::new();
285 io::stdin().read_line(&mut value)?;
286 Ok(value.trim().to_owned())
287}
288
289fn build_service_config(setup: ServiceSetup) -> String {
290 let mut output = String::new();
291 output.push_str("version = 1\n\n");
292 output.push_str("[providers.onepassword]\n");
293 output.push_str("type = \"1password\"\n");
294 output.push_str("cache = \"daemon\"\n\n");
295 output.push_str(&format!("[services.{}]\n", toml_key(&setup.service_name)));
296 output.push_str(&format!(
297 "description = {}\n",
298 toml_string(&format!("{} access", setup.service_name))
299 ));
300 output.push_str("provider = \"onepassword\"\n\n");
301 output.push_str(&format!(
302 "[services.{}.secrets]\n",
303 toml_key(&setup.service_name)
304 ));
305 output.push_str(&format!(
306 "{} = {}\n\n",
307 toml_key(&setup.secret_name),
308 toml_string(&setup.secret_reference)
309 ));
310 if let (Some(name), Some(reference)) = (
311 &setup.private_key_secret_name,
312 &setup.private_key_secret_reference,
313 ) {
314 output.truncate(output.trim_end_matches('\n').len());
315 output.push('\n');
316 output.push_str(&format!(
317 "{} = {}\n\n",
318 toml_key(name),
319 toml_string(reference)
320 ));
321 }
322
323 if let Some(rest) = setup.rest {
324 output.push_str(&format!(
325 "[services.{}.commands.{}]\n",
326 toml_key(&setup.service_name),
327 toml_key(&rest.command_name)
328 ));
329 output
330 .push_str("description = \"Call the configured REST API. Prefer this for agents.\"\n");
331 output.push_str("mode = \"rest\"\n");
332 output.push_str(&format!("base_url = {}\n", toml_string(&rest.base_url)));
333 output.push_str(&format!(
334 "method_default = {}\n\n",
335 toml_string(&rest.method_default)
336 ));
337 output.push_str(&format!(
338 "[services.{}.commands.{}.auth]\n",
339 toml_key(&setup.service_name),
340 toml_key(&rest.command_name)
341 ));
342 match rest.auth {
343 RestAuthSetup::Bearer => {
344 output.push_str("type = \"bearer\"\n");
345 output.push_str(&format!("secret = {}\n\n", toml_string(&setup.secret_name)));
346 }
347 RestAuthSetup::GitHubApp => {
348 let private_key = setup
349 .private_key_secret_name
350 .as_deref()
351 .unwrap_or("private_key");
352 output.push_str("type = \"github_app\"\n");
353 output.push_str(&format!(
354 "credential = {}\n",
355 toml_string(&setup.secret_name)
356 ));
357 output.push_str(&format!("private_key = {}\n\n", toml_string(private_key)));
358 }
359 }
360 }
361
362 if let Some(delegated) = setup.delegated {
363 output.push_str(&format!(
364 "[services.{}.commands.{}]\n",
365 toml_key(&setup.service_name),
366 toml_key(&delegated.command_name)
367 ));
368 output
369 .push_str("description = \"Run the configured trusted CLI with a secret injected.\"\n");
370 output.push_str("mode = \"delegated\"\n");
371 output.push_str(&format!("program = {}\n", toml_string(&delegated.program)));
372 output.push_str(&format!(
373 "check = {}\n\n",
374 toml_array(&delegated.check_args)
375 ));
376 output.push_str(&format!(
377 "[services.{}.commands.{}.inject.env.{}]\n",
378 toml_key(&setup.service_name),
379 toml_key(&delegated.command_name),
380 toml_key(&delegated.env_var)
381 ));
382 output.push_str(&format!("secret = {}\n", toml_string(&setup.secret_name)));
383 }
384
385 output
386}
387
388fn empty_config() -> &'static str {
389 r#"version = 1
390
391[providers.onepassword]
392type = "1password"
393cache = "daemon"
394
395"#
396}
397
398fn split_args(value: &str) -> Vec<String> {
399 value.split_whitespace().map(str::to_owned).collect()
400}
401
402fn toml_key(value: &str) -> String {
403 toml_string(value)
404}
405
406fn toml_array(values: &[String]) -> String {
407 let values = values
408 .iter()
409 .map(|value| toml_string(value))
410 .collect::<Vec<_>>()
411 .join(", ");
412 format!("[{values}]")
413}
414
415fn toml_string(value: &str) -> String {
416 let mut escaped = String::new();
417 for character in value.chars() {
418 match character {
419 '\\' => escaped.push_str("\\\\"),
420 '"' => escaped.push_str("\\\""),
421 '\n' => escaped.push_str("\\n"),
422 '\r' => escaped.push_str("\\r"),
423 '\t' => escaped.push_str("\\t"),
424 other => escaped.push(other),
425 }
426 }
427 format!("\"{escaped}\"")
428}
429
430fn print_missing_config(path: &Path) {
431 println!("No via config found at:");
432 println!(" {}", path.display());
433 println!();
434 println!("Human setup:");
435 println!(" Run `via config` in an interactive terminal to create one.");
436 println!();
437 println!("Agent guidance:");
438 println!(" Ask the user to run `via config`, then rerun `via config doctor`.");
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444
445 #[test]
446 fn builds_generic_rest_config() {
447 let config = build_service_config(ServiceSetup {
448 service_name: "gitlab".to_owned(),
449 secret_name: "token".to_owned(),
450 secret_reference: "op://Private/GitLab/token".to_owned(),
451 private_key_secret_name: None,
452 private_key_secret_reference: None,
453 rest: Some(RestSetup {
454 command_name: "api".to_owned(),
455 base_url: "https://gitlab.example.com/api/v4".to_owned(),
456 method_default: "GET".to_owned(),
457 auth: RestAuthSetup::Bearer,
458 }),
459 delegated: None,
460 });
461
462 assert!(config.contains("[services.\"gitlab\"]"));
463 assert!(config.contains("cache = \"daemon\""));
464 assert!(config.contains("base_url = \"https://gitlab.example.com/api/v4\""));
465 assert!(Config::from_toml_str(&config).is_ok());
466 }
467
468 #[test]
469 fn builds_github_app_rest_config() {
470 let config = build_service_config(ServiceSetup {
471 service_name: "github".to_owned(),
472 secret_name: "app".to_owned(),
473 secret_reference: "op://Private/Example GitHub App/metadata".to_owned(),
474 private_key_secret_name: Some("private_key".to_owned()),
475 private_key_secret_reference: Some(
476 "op://Private/Example GitHub App/github-app.private-key.pem".to_owned(),
477 ),
478 rest: Some(RestSetup {
479 command_name: "api".to_owned(),
480 base_url: "https://api.github.com".to_owned(),
481 method_default: "GET".to_owned(),
482 auth: RestAuthSetup::GitHubApp,
483 }),
484 delegated: None,
485 });
486
487 assert!(config.contains("type = \"github_app\""));
488 assert!(config.contains("cache = \"daemon\""));
489 assert!(config.contains("credential = \"app\""));
490 assert!(config.contains("private_key = \"private_key\""));
491 assert!(Config::from_toml_str(&config).is_ok());
492 }
493
494 #[test]
495 fn builds_generic_delegated_config() {
496 let config = build_service_config(ServiceSetup {
497 service_name: "deploy tool".to_owned(),
498 secret_name: "api token".to_owned(),
499 secret_reference: "op://Private/Deploy/token".to_owned(),
500 private_key_secret_name: None,
501 private_key_secret_reference: None,
502 rest: None,
503 delegated: Some(DelegatedSetup {
504 command_name: "cli".to_owned(),
505 program: "deployctl".to_owned(),
506 env_var: "DEPLOY_TOKEN".to_owned(),
507 check_args: vec!["--version".to_owned()],
508 }),
509 });
510
511 assert!(config.contains("[services.\"deploy tool\"]"));
512 assert!(config
513 .contains("[services.\"deploy tool\".commands.\"cli\".inject.env.\"DEPLOY_TOKEN\"]"));
514 assert!(Config::from_toml_str(&config).is_ok());
515 }
516
517 #[test]
518 fn escapes_toml_strings() {
519 assert_eq!(toml_string("a\"b\\c"), "\"a\\\"b\\\\c\"");
520 }
521}