1use std::collections::{BTreeMap, BTreeSet};
4use std::io::{self, BufRead, Write};
5use std::path::{Path, PathBuf};
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8use anyhow::{Context, Result, ensure};
9
10use crate::doctor::{
11 CheckStatus, DoctorCheck, DoctorOptions, all_ready, apple_container_daemon_check,
12 current_apple_platform, local_docker_runtime_check, local_podman_runtime_check, probe_executor,
13 render_human, run_with_config_path,
14};
15use crate::targets::{
16 CancellableProcessExecutor, CommandExecutor, CommandSpec,
17 ContainerTemplate as RuntimeContainerTemplate, ProcessExecutor,
18 TargetTemplate as RuntimeTargetTemplate, run_setup_smoke_test,
19};
20use mj_core::config::{
21 AwsAddressSource, Config, ContainerTemplate, HarnessKind, HarnessProfile, PermissionMode,
22 ProjectBundle, ProjectRepository, SshConnection, TargetTemplate, unique_config_id as unique_id,
23 validate_id,
24};
25
26const AWS_PROBE_TIMEOUT: Duration = Duration::from_secs(8);
29
30const DEFAULT_AWS_SSH_USER: &str = "ubuntu";
33const AWS_TARGET_ID: &str = "aws";
34
35pub use mj_client::target::DEFAULT_IMAGE;
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct DiscoveredHome {
43 pub kind: HarnessKind,
44 pub path: PathBuf,
45 pub authenticated: bool,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct GithubRepository {
50 pub owner: String,
51 pub repository: String,
52}
53
54impl GithubRepository {
55 fn source(&self) -> String {
56 format!("{}/{}", self.owner, self.repository)
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum RuntimeKind {
62 Podman,
63 Docker,
64 AppleContainer,
65}
66
67impl RuntimeKind {
68 fn id(self) -> &'static str {
69 match self {
70 Self::Podman => "podman",
71 Self::Docker => "docker",
72 Self::AppleContainer => "apple-container",
73 }
74 }
75
76 fn label(self) -> &'static str {
77 match self {
78 Self::Podman => "Podman",
79 Self::Docker => "Docker",
80 Self::AppleContainer => "Apple container",
81 }
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct RuntimeProbe {
87 pub kind: RuntimeKind,
88 pub usable: bool,
89 pub detail: String,
90 pub remediation: Option<String>,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct AwsAccount {
98 pub account: String,
99 pub arn: String,
100 pub region: Option<String>,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct AwsTargetInput {
107 pub launch_template: String,
108 pub region: String,
109 pub ssh_user: String,
110 pub identity_file: Option<PathBuf>,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
115pub enum SshTargetKind {
116 Bare { permissions: PermissionMode },
117 Podman { image: String },
118 Docker { image: String },
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct SshTargetInput {
124 pub name: String,
125 pub host: String,
126 pub kind: SshTargetKind,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct SetupDiscovery {
131 pub homes: Vec<DiscoveredHome>,
132 pub repository: Option<GithubRepository>,
133 pub runtimes: Vec<RuntimeProbe>,
134 pub aws: Option<AwsAccount>,
137 pub ssh_hosts: Vec<String>,
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum SetupOutcome {
144 Written,
145 Cancelled,
146}
147
148pub fn initialize_local_startup_config(config_path: &Path) -> Result<()> {
152 #[cfg(unix)]
153 {
154 let config = Config::load_from(config_path)?;
155 if config.is_unconfigured() {
156 let kind = HarnessKind::Codex;
157 let home = std::env::var_os(kind.home_env())
158 .map(|value| kind.home_from_environment(value))
159 .or_else(|| dirs::home_dir().map(|home| home.join(kind.default_home_leaf())))
160 .context("locate Codex home for the default local profile")?;
161 let home = std::path::absolute(home).context("resolve Codex profile home")?;
162 Config::update_to(config_path, |fresh| {
163 if fresh.is_unconfigured() {
164 configure_local_startup(fresh, home);
165 }
166 Ok(())
167 })?;
168 }
169 }
170 #[cfg(not(unix))]
172 let _ = config_path;
173 Ok(())
174}
175
176#[cfg(unix)]
177fn configure_local_startup(config: &mut Config, codex_home: PathBuf) {
178 config.profiles.insert(
179 "codex".into(),
180 HarnessProfile {
181 enabled: true,
182 kind: HarnessKind::Codex,
183 home: codex_home,
184 environment: BTreeMap::new(),
185 context_window_bytes: None,
186 guardian_review_model: None,
187 },
188 );
189 config
190 .targets
191 .insert("localhost".into(), TargetTemplate::LocalBare);
192}
193
194pub fn run_setup_dialog(config_path: &Path) -> Result<SetupOutcome> {
196 let probes = probe_executor();
200 let discovery = discover_current(&probes);
201 let stdout = io::stdout();
202 let mut input = ReadlinePrompter::default();
203 run_setup_dialog_inner(
204 &mut input,
205 &mut stdout.lock(),
206 config_path,
207 &discovery,
208 &ProcessExecutor,
209 &probes,
210 )
211}
212
213pub fn discover_current(executor: &impl CommandExecutor) -> SetupDiscovery {
214 let home = dirs::home_dir();
215 let overrides = HarnessKind::ALL
216 .into_iter()
217 .filter_map(|kind| {
218 std::env::var_os(kind.home_env()).map(|path| (kind, kind.home_from_environment(path)))
219 })
220 .collect::<BTreeMap<_, _>>();
221 let mut homes =
222 discover_harness_homes_with_executor(home.as_deref(), overrides.clone(), executor);
223 discover_installed_harnesses(home.as_deref(), &overrides, &mut homes, executor);
224 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
225
226 SetupDiscovery {
227 homes,
228 repository: discover_github_repository(executor, &cwd),
229 runtimes: probe_local_runtimes(executor, cfg!(target_os = "macos")),
230 aws: detect_aws(&CancellableProcessExecutor::with_timeout(AWS_PROBE_TIMEOUT)),
231 ssh_hosts: discover_ssh_hosts(home.as_deref()),
232 }
233}
234
235pub fn discover_ssh_hosts(home: Option<&Path>) -> Vec<String> {
243 let Some(home) = home else {
244 return Vec::new();
245 };
246 let Ok(contents) = std::fs::read_to_string(home.join(".ssh").join("config")) else {
247 return Vec::new();
248 };
249 ssh_config_aliases(&contents)
250}
251
252pub fn ssh_config_aliases(contents: &str) -> Vec<String> {
257 let mut aliases: Vec<String> = Vec::new();
258 for line in contents.lines() {
259 let line = line.trim();
260 if line.is_empty() || line.starts_with('#') {
261 continue;
262 }
263 let Some((keyword, rest)) = line.split_once(char::is_whitespace) else {
264 continue;
265 };
266 if !keyword.eq_ignore_ascii_case("host") {
267 continue;
268 }
269 for alias in rest.split_whitespace() {
270 let alias = alias.trim_matches('"');
271 if alias.is_empty() || alias.contains(['*', '?', '!']) {
272 continue;
273 }
274 if !aliases.iter().any(|existing| existing == alias) {
275 aliases.push(alias.to_owned());
276 }
277 }
278 }
279 aliases
280}
281
282fn discover_installed_harnesses(
285 user_home: Option<&Path>,
286 overrides: &BTreeMap<HarnessKind, PathBuf>,
287 homes: &mut Vec<DiscoveredHome>,
288 executor: &impl CommandExecutor,
289) {
290 for kind in HarnessKind::ALL {
291 if homes.iter().any(|home| home.kind == kind) {
292 continue;
293 }
294 let Some(home) = overrides
295 .get(&kind)
296 .cloned()
297 .or_else(|| user_home.map(|home| home.join(kind.default_home_leaf())))
298 else {
299 continue;
300 };
301 let probe = CommandSpec::new(kind.cli_binary_name(), ["--version"])
302 .purpose("detect installed harness before first login");
303 match executor.execute(&probe) {
304 Ok(output) if output.status == 0 => homes.push(DiscoveredHome {
305 kind,
306 path: home,
307 authenticated: false,
308 }),
309 Ok(_) => {}
310 Err(error) => tracing::debug!(
311 harness = kind.id(),
312 "installation probe unavailable: {error:#}"
313 ),
314 }
315 }
316}
317
318pub(crate) fn discover_harness_homes_with_executor(
319 home: Option<&Path>,
320 overrides: impl IntoIterator<Item = (HarnessKind, PathBuf)>,
321 executor: &impl CommandExecutor,
322) -> Vec<DiscoveredHome> {
323 let mut candidates = Vec::new();
324 if let Some(home) = home {
325 candidates.extend(
326 HarnessKind::ALL
327 .into_iter()
328 .map(|kind| (kind, home.join(kind.default_home_leaf()), true)),
329 );
330 }
331 candidates.extend(
332 overrides
333 .into_iter()
334 .map(|(kind, path)| (kind, path, false)),
335 );
336
337 let mut seen = BTreeSet::new();
338 candidates
339 .into_iter()
340 .filter(|(kind, path, _)| seen.insert((*kind, path.clone())) && path.is_dir())
341 .map(|(kind, path, is_default_home)| DiscoveredHome {
342 authenticated: harness_is_authenticated_with(
343 &probe_profile(kind, &path),
344 is_default_home,
345 executor,
346 ),
347 kind,
348 path,
349 })
350 .collect()
351}
352
353fn probe_profile(kind: HarnessKind, home: &Path) -> HarnessProfile {
357 HarnessProfile {
358 enabled: true,
359 kind,
360 home: home.to_path_buf(),
361 environment: BTreeMap::new(),
362 context_window_bytes: None,
363 guardian_review_model: None,
364 }
365}
366
367pub(crate) fn harness_is_authenticated_with_executor(
368 profile: &HarnessProfile,
369 executor: &impl CommandExecutor,
370) -> bool {
371 let is_default_home = dirs::home_dir()
372 .is_some_and(|user_home| profile.home == user_home.join(profile.kind.default_home_leaf()));
373 harness_is_authenticated_with(profile, is_default_home, executor)
374}
375
376fn harness_is_authenticated_with(
383 profile: &HarnessProfile,
384 is_default_home: bool,
385 executor: &impl CommandExecutor,
386) -> bool {
387 let kind = profile.kind;
388 let home = profile.home.as_path();
389 if profile.authentication_marker().is_file()
390 || (kind == HarnessKind::Kimi && home.join("credentials").is_file())
391 {
392 return true;
393 }
394 if kind != HarnessKind::Claude {
395 return false;
396 }
397 if is_default_home && claude_keychain_reports_authenticated(executor) {
398 return true;
399 }
400 if !is_default_home && claude_cli_reports_authenticated(home, executor) {
401 return true;
402 }
403 false
404}
405
406fn claude_cli_reports_authenticated(home: &Path, executor: &impl CommandExecutor) -> bool {
410 let mut command = CommandSpec::new("claude", ["auth", "status", "--json"])
411 .purpose("check Claude Code authentication");
412 command.env.insert(
413 HarnessKind::Claude.home_env().to_owned(),
414 home.to_string_lossy().into_owned(),
415 );
416 let Ok(output) = executor.execute(&command) else {
417 return false;
418 };
419 if output.status != 0 {
420 return false;
421 }
422 serde_json::from_slice::<serde_json::Value>(&output.stdout)
423 .ok()
424 .and_then(|status| status.get("loggedIn").and_then(serde_json::Value::as_bool))
425 == Some(true)
426}
427
428#[cfg(target_os = "macos")]
432fn claude_keychain_reports_authenticated(executor: &impl CommandExecutor) -> bool {
433 let command = CommandSpec::new(
434 "security",
435 [
436 "find-generic-password",
437 "-s",
438 "Claude Code-credentials",
439 "-w",
440 ],
441 )
442 .purpose("check Claude Code authentication in the macOS Keychain");
443 let Ok(output) = executor.execute(&command) else {
444 return false;
445 };
446 output.status == 0 && claude_credentials_contain_login(&output.stdout)
447}
448
449#[cfg(not(target_os = "macos"))]
450fn claude_keychain_reports_authenticated(_executor: &impl CommandExecutor) -> bool {
451 false
452}
453
454#[cfg(any(target_os = "macos", test))]
455fn claude_credentials_contain_login(credentials: &[u8]) -> bool {
456 let Ok(document) = serde_json::from_slice::<serde_json::Value>(credentials) else {
457 return false;
458 };
459 [
460 "/claudeAiOauth/accessToken",
461 "/claudeAiOauth/refreshToken",
462 "/oauth/accessToken",
463 "/apiKey",
464 ]
465 .into_iter()
466 .any(|pointer| {
467 document
468 .pointer(pointer)
469 .and_then(serde_json::Value::as_str)
470 .is_some_and(|value| !value.trim().is_empty())
471 })
472}
473
474pub fn github_repository_from_origin(origin: &str) -> Option<GithubRepository> {
475 let origin = origin.trim();
476 let path = origin
477 .strip_prefix("https://github.com/")
478 .or_else(|| origin.strip_prefix("http://github.com/"))
479 .or_else(|| origin.strip_prefix("git@github.com:"))
480 .or_else(|| origin.strip_prefix("ssh://git@github.com/"))
481 .unwrap_or(origin);
484 let path = path.trim_end_matches(".git");
485 let mut parts = path.split('/');
486 let owner = parts.next()?;
487 let repository = parts.next()?;
488 if owner.is_empty()
489 || repository.is_empty()
490 || parts.next().is_some()
491 || owner.chars().any(char::is_whitespace)
492 || repository.chars().any(char::is_whitespace)
493 {
494 return None;
495 }
496 Some(GithubRepository {
497 owner: owner.to_owned(),
498 repository: repository.to_owned(),
499 })
500}
501
502fn discover_github_repository(
508 executor: &impl CommandExecutor,
509 cwd: &Path,
510) -> Option<GithubRepository> {
511 let command = CommandSpec::new(
512 "git",
513 [
514 "-C".to_owned(),
515 cwd.to_string_lossy().into_owned(),
516 "remote".to_owned(),
517 "get-url".to_owned(),
518 "origin".to_owned(),
519 ],
520 )
521 .purpose("detect the current repository's GitHub origin");
522 let output = executor.execute(&command).ok()?;
523 if output.status != 0 {
524 return None;
525 }
526 github_repository_from_origin(&String::from_utf8_lossy(&output.stdout))
527}
528
529pub fn probe_local_runtimes(executor: &impl CommandExecutor, is_macos: bool) -> Vec<RuntimeProbe> {
532 let mut probes = vec![
533 runtime_probe_from_check(RuntimeKind::Podman, local_podman_runtime_check(executor)),
534 runtime_probe_from_check(RuntimeKind::Docker, local_docker_runtime_check(executor)),
535 ];
536 if is_macos {
537 probes.push(runtime_probe_from_check(
538 RuntimeKind::AppleContainer,
539 apple_container_daemon_check(executor),
540 ));
541 }
542 probes
543}
544
545fn runtime_probe_from_check(kind: RuntimeKind, check: crate::doctor::DoctorCheck) -> RuntimeProbe {
546 RuntimeProbe {
547 kind,
548 usable: check.status == CheckStatus::Ready,
549 detail: check.detail,
550 remediation: check.remediation,
551 }
552}
553
554pub fn detect_aws(executor: &impl CommandExecutor) -> Option<AwsAccount> {
560 let identity = CommandSpec::new("aws", ["sts", "get-caller-identity", "--output", "json"])
561 .purpose("detect AWS credentials");
562 let output = executor.execute(&identity).ok()?;
563 if output.status != 0 {
564 return None;
565 }
566 let identity: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
567 let account = identity.get("Account")?.as_str()?.to_owned();
568 let arn = identity.get("Arn")?.as_str()?.to_owned();
569 Some(AwsAccount {
570 account,
571 arn,
572 region: configured_aws_region(executor),
573 })
574}
575
576fn configured_aws_region(executor: &impl CommandExecutor) -> Option<String> {
577 let command = CommandSpec::new("aws", ["configure", "get", "region"])
578 .purpose("read the default AWS region");
579 let output = executor.execute(&command).ok()?;
580 if output.status != 0 {
581 return None;
582 }
583 let region = String::from_utf8_lossy(&output.stdout).trim().to_owned();
584 (!region.is_empty()).then_some(region)
585}
586
587pub fn build_config(
588 homes: &[DiscoveredHome],
589 repository: Option<&GithubRepository>,
590 runtime: RuntimeKind,
591 image: &str,
592) -> Config {
593 build_config_with_runtime(homes, repository, Some((runtime, image)), None, None)
594}
595
596fn build_config_with_runtime(
597 homes: &[DiscoveredHome],
598 repository: Option<&GithubRepository>,
599 runtime: Option<(RuntimeKind, &str)>,
600 aws: Option<&AwsTargetInput>,
601 ssh: Option<&SshTargetInput>,
602) -> Config {
603 build_config_with_runtimes(
604 homes,
605 repository,
606 &runtime.into_iter().collect::<Vec<_>>(),
607 aws,
608 ssh,
609 )
610}
611
612fn build_config_with_runtimes(
613 homes: &[DiscoveredHome],
614 repository: Option<&GithubRepository>,
615 runtimes: &[(RuntimeKind, &str)],
616 aws: Option<&AwsTargetInput>,
617 ssh: Option<&SshTargetInput>,
618) -> Config {
619 let mut config = Config::default();
620 for home in homes {
621 let id = unique_id(&config.profiles, home.kind.id());
622 config.profiles.insert(
623 id,
624 HarnessProfile {
625 enabled: true,
626 kind: home.kind,
627 home: home.path.clone(),
628 environment: BTreeMap::new(),
629 context_window_bytes: None,
630 guardian_review_model: None,
631 },
632 );
633 }
634
635 if let Some(repository) = repository {
636 let repository_id = config_id(&repository.repository);
637 config.bundles.insert(
638 "current-repository".to_owned(),
639 ProjectBundle {
640 primary_repo: repository_id.clone(),
641 repositories: vec![ProjectRepository {
642 id: repository_id.clone(),
643 github: Some(repository.source()),
644 local: None,
645 destination: PathBuf::from(repository_id),
646 git_ref: None,
647 }],
648 },
649 );
650 }
651
652 #[cfg(unix)]
653 config
654 .targets
655 .insert("localhost".to_owned(), TargetTemplate::LocalBare);
656 for (runtime, image) in runtimes {
657 let (target_id, target) = local_runtime_target(*runtime, image);
658 config.targets.insert(target_id.to_owned(), target);
659 }
660 if let Some(aws) = aws {
661 config.targets.insert(
662 AWS_TARGET_ID.to_owned(),
663 TargetTemplate::AwsEc2 {
664 aws_profile: None,
665 region: aws.region.clone(),
666 launch_template: aws.launch_template.clone(),
667 launch_template_version: None,
668 ssh_user: aws.ssh_user.clone(),
669 address_source: AwsAddressSource::default(),
670 identity_file: aws.identity_file.clone(),
671 ssh_args: vec![],
672 },
673 );
674 }
675 if let Some(ssh) = ssh {
676 let connection = SshConnection {
679 host: ssh.host.clone(),
680 user: None,
681 identity_file: None,
682 extra_args: vec![],
683 };
684 let target = match &ssh.kind {
685 SshTargetKind::Bare { permissions } => TargetTemplate::SshBare {
686 ssh: connection,
687 permissions: *permissions,
688 workspace_prefix: default_ssh_workspace_prefix(),
689 },
690 SshTargetKind::Podman { image } => TargetTemplate::SshPodman {
691 ssh: connection,
692 container: ContainerTemplate {
693 build_cache: None,
694 image: image.clone(),
695 pull_policy: Default::default(),
696 platform: None,
697 cpus: None,
698 memory: None,
699 environment: BTreeMap::new(),
700 workspace_storage: Default::default(),
701 },
702 },
703 SshTargetKind::Docker { image } => TargetTemplate::SshDocker {
704 ssh: connection,
705 container: ContainerTemplate {
706 build_cache: None,
707 image: image.clone(),
708 pull_policy: Default::default(),
709 platform: None,
710 cpus: None,
711 memory: None,
712 environment: BTreeMap::new(),
713 workspace_storage: Default::default(),
714 },
715 },
716 };
717 config
721 .targets
722 .insert(unique_id(&config.targets, &ssh.name), target);
723 }
724 config
725}
726
727pub fn local_runtime_target(runtime: RuntimeKind, image: &str) -> (&'static str, TargetTemplate) {
729 let container = ContainerTemplate {
730 build_cache: None,
731 image: image.trim().to_owned(),
732 pull_policy: Default::default(),
733 platform: None,
734 cpus: None,
735 memory: None,
736 environment: BTreeMap::new(),
737 workspace_storage: Default::default(),
738 };
739 match runtime {
740 RuntimeKind::Podman => ("podman", TargetTemplate::LocalPodman { container }),
741 RuntimeKind::Docker => ("docker", TargetTemplate::LocalDocker { container }),
742 RuntimeKind::AppleContainer => (
743 "apple-container",
744 TargetTemplate::AppleContainer { container },
745 ),
746 }
747}
748
749fn default_ssh_workspace_prefix() -> PathBuf {
751 PathBuf::from(".local/share/hel/workspaces")
752}
753
754fn config_id(value: &str) -> String {
755 let mut id = value
756 .chars()
757 .filter(|character| {
758 character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
759 })
760 .take(64)
761 .collect::<String>();
762 if id.is_empty() || matches!(id.as_str(), "." | "..") {
763 id = "repository".to_owned();
764 }
765 id
766}
767
768pub fn run_setup_dialog_with(
775 input: &mut impl BufRead,
776 output: &mut impl Write,
777 config_path: &Path,
778 discovery: &SetupDiscovery,
779 smoke_executor: &impl CommandExecutor,
780 probe_executor: &impl CommandExecutor,
781) -> Result<SetupOutcome> {
782 run_setup_dialog_inner(
783 input,
784 output,
785 config_path,
786 discovery,
787 smoke_executor,
788 probe_executor,
789 )
790}
791
792fn run_setup_dialog_inner(
793 input: &mut impl SetupPrompter,
794 output: &mut impl Write,
795 config_path: &Path,
796 discovery: &SetupDiscovery,
797 smoke_executor: &impl CommandExecutor,
798 probe_executor: &impl CommandExecutor,
799) -> Result<SetupOutcome> {
800 let existing = Config::load_from(config_path)?;
801 writeln!(output, "Welcome to Mjolnir setup.")?;
802 writeln!(output)?;
803 write_discovered_homes(output, &discovery.homes)?;
804 write_repository(output, discovery.repository.as_ref())?;
805 write_runtimes(output, &discovery.runtimes)?;
806
807 let runtimes = if discovery.runtimes.iter().any(|runtime| runtime.usable) {
808 let image = prompt(
809 input,
810 output,
811 &format!("Container image [{DEFAULT_IMAGE}]: "),
812 )?;
813 let image = if image.is_empty() {
814 DEFAULT_IMAGE.to_owned()
815 } else {
816 image
817 };
818 discovery
819 .runtimes
820 .iter()
821 .filter(|runtime| runtime.usable)
822 .map(|runtime| (runtime.kind, image.clone()))
823 .collect::<Vec<_>>()
824 } else {
825 writeln!(
826 output,
827 "No usable container runtime found; raw localhost will still be configured."
828 )?;
829 Vec::new()
830 };
831 let aws = prompt_aws_target(input, output, discovery.aws.as_ref())?;
832 let runtime_choices = runtimes
833 .iter()
834 .map(|(runtime, image)| (*runtime, image.as_str()))
835 .collect::<Vec<_>>();
836 let configured = build_config_with_runtimes(
839 &discovery.homes,
840 discovery.repository.as_ref(),
841 &runtime_choices,
842 aws.as_ref(),
843 None,
844 );
845 let ssh = prompt_ssh_target(input, output, &discovery.ssh_hosts, &configured.targets)?;
846 let config = build_config_with_runtimes(
847 &discovery.homes,
848 discovery.repository.as_ref(),
849 &runtime_choices,
850 aws.as_ref(),
851 ssh.as_ref(),
852 );
853 config.validate()?;
854 let additions = reconcile_setup(input, output, &existing, config)?;
855 let runtimes = runtimes
856 .into_iter()
857 .filter(|(runtime, image)| {
858 let (_, target) = local_runtime_target(*runtime, image);
859 additions.targets.values().any(|added| added == &target)
860 })
861 .collect::<Vec<_>>();
862
863 writeln!(output)?;
864 write_summary(output, config_path, &additions, &runtimes)?;
865 let confirmation = prompt(input, output, "Write this configuration? [y/N]: ")?;
866 if !matches!(confirmation.to_ascii_lowercase().as_str(), "y" | "yes") {
867 writeln!(output, "Setup cancelled.")?;
868 return Ok(SetupOutcome::Cancelled);
869 }
870
871 writeln!(output, "Writing {}...", config_path.display())?;
872 Config::update_to(config_path, |latest| {
873 apply_setup_additions(latest, &additions)
874 })?;
875 let smoke_failures = runtimes
879 .iter()
880 .filter_map(|(runtime, image)| {
881 let target = smoke_target(*runtime, image);
882 run_smoke_test(output, &target, smoke_executor)
883 .err()
884 .map(|error| smoke_failure_check(*runtime, image, &error))
885 })
886 .collect();
887 write_doctor_report(output, config_path, probe_executor, smoke_failures)?;
888 writeln!(
889 output,
890 "Advanced users can edit TOML for extra profiles, virtual monorepos, SSH, and AWS."
891 )?;
892 writeln!(output, "Press n to start your first session.")?;
893 Ok(SetupOutcome::Written)
894}
895
896fn reconcile_setup(
898 input: &mut impl SetupPrompter,
899 output: &mut impl Write,
900 existing: &Config,
901 discovered: Config,
902) -> Result<Config> {
903 let mut additions = existing.setup_additions(&discovered);
904 for id in additions.profiles.keys() {
905 writeln!(output, "Adding discovered profile {id}.")?;
906 }
907 for id in additions.bundles.keys() {
908 writeln!(output, "Adding repository bundle {id}.")?;
909 }
910 for (id, target) in &discovered.targets {
911 if !existing.targets.contains_key(id) {
912 continue;
913 }
914 let alternate = additions
915 .targets
916 .iter()
917 .find(|(_, added)| *added == target)
918 .map(|(id, _)| id.clone());
919 let Some(alternate) = alternate else { continue };
920 writeln!(
921 output,
922 "Target {id} already has different settings. Existing sessions will keep using it."
923 )?;
924 let answer = prompt(
925 input,
926 output,
927 &format!("Keep {id}, or add the discovered settings as {alternate}? [K/a]: "),
928 )?;
929 if !matches!(answer.to_ascii_lowercase().as_str(), "a" | "add") {
930 additions.targets.remove(&alternate);
931 writeln!(
932 output,
933 "Keeping target {id}; discovered settings were not added."
934 )?;
935 }
936 }
937 Ok(additions)
938}
939
940fn apply_setup_additions(latest: &mut Config, additions: &Config) -> Result<()> {
941 fn add<T: Clone>(
942 section: &str,
943 latest: &mut BTreeMap<String, T>,
944 additions: &BTreeMap<String, T>,
945 same: impl Fn(&T, &T) -> bool,
946 ) -> Result<()> {
947 for (id, value) in additions {
948 if latest.values().any(|existing| same(existing, value)) {
949 continue;
950 }
951 ensure!(
952 !latest.contains_key(id),
953 "{section} {id:?} changed while setup was open; no configuration was written. Rerun mj setup to review the current settings"
954 );
955 latest.insert(id.clone(), value.clone());
956 }
957 Ok(())
958 }
959 add(
960 "profile",
961 &mut latest.profiles,
962 &additions.profiles,
963 HarnessProfile::same_installation,
964 )?;
965 add(
966 "bundle",
967 &mut latest.bundles,
968 &additions.bundles,
969 PartialEq::eq,
970 )?;
971 add(
972 "target",
973 &mut latest.targets,
974 &additions.targets,
975 PartialEq::eq,
976 )?;
977 latest.validate()
978}
979
980fn write_discovered_homes(output: &mut impl Write, homes: &[DiscoveredHome]) -> Result<()> {
981 writeln!(output, "Harness homes:")?;
982 if homes.is_empty() {
983 writeln!(
984 output,
985 " No existing Codex, Claude Code, Kimi Code, or Grok Build homes found."
986 )?;
987 }
988 for home in homes {
989 let authentication = if home.authenticated {
990 "authenticated"
991 } else {
992 "not authenticated"
993 };
994 writeln!(
995 output,
996 " {}: {} ({authentication}){}",
997 home.kind.display_name(),
998 home.path.display(),
999 match home.kind.unsandboxed_guardian_warning() {
1000 Some(warning) => format!(" — {warning}"),
1001 None => String::new(),
1002 }
1003 )?;
1004 }
1005 Ok(())
1006}
1007
1008fn write_repository(output: &mut impl Write, repository: Option<&GithubRepository>) -> Result<()> {
1009 match repository {
1010 Some(repository) => writeln!(
1011 output,
1012 "GitHub origin: {} (a one-repository bundle will be created)",
1013 repository.source()
1014 )?,
1015 None => writeln!(
1016 output,
1017 "GitHub origin: none detected in the current directory."
1018 )?,
1019 }
1020 Ok(())
1021}
1022
1023fn write_runtimes(output: &mut impl Write, runtimes: &[RuntimeProbe]) -> Result<()> {
1024 writeln!(output, "Local runtimes:")?;
1025 for runtime in runtimes {
1026 let state = if runtime.usable {
1027 "usable"
1028 } else {
1029 "unavailable"
1030 };
1031 if runtime.detail.is_empty() {
1032 writeln!(output, " {}: {state}", runtime.kind.label())?;
1033 } else {
1034 writeln!(
1035 output,
1036 " {}: {state} ({})",
1037 runtime.kind.label(),
1038 runtime.detail
1039 )?;
1040 }
1041 if let Some(remediation) = &runtime.remediation {
1042 writeln!(output, " remediation: {remediation}")?;
1043 }
1044 }
1045 Ok(())
1046}
1047
1048fn prompt_aws_target(
1051 input: &mut impl SetupPrompter,
1052 output: &mut impl Write,
1053 account: Option<&AwsAccount>,
1054) -> Result<Option<AwsTargetInput>> {
1055 let Some(account) = account else {
1056 writeln!(
1057 output,
1058 "AWS: no working `aws` CLI credentials found; skipping the AWS target."
1059 )?;
1060 return Ok(None);
1061 };
1062 writeln!(
1063 output,
1064 "AWS: credentials are valid for account {} ({}).",
1065 account.account, account.arn
1066 )?;
1067 let answer = prompt(input, output, "Add an AWS EC2 target? [y/N]: ")?;
1068 if !matches!(answer.to_ascii_lowercase().as_str(), "y" | "yes") {
1069 return Ok(None);
1070 }
1071
1072 let launch_template = prompt(input, output, "Launch template name: ")?;
1073 if launch_template.is_empty() {
1074 writeln!(
1075 output,
1076 "A launch template name is required; skipping the AWS target."
1077 )?;
1078 return Ok(None);
1079 }
1080
1081 let region_label = match &account.region {
1082 Some(region) => format!("Region [{region}]: "),
1083 None => "Region: ".to_owned(),
1084 };
1085 let region = prompt(input, output, ®ion_label)?;
1086 let region = if region.is_empty() {
1087 match &account.region {
1088 Some(region) => region.clone(),
1089 None => {
1090 writeln!(output, "A region is required; skipping the AWS target.")?;
1091 return Ok(None);
1092 }
1093 }
1094 } else {
1095 region
1096 };
1097
1098 let ssh_user = prompt(
1099 input,
1100 output,
1101 &format!("SSH user [{DEFAULT_AWS_SSH_USER}]: "),
1102 )?;
1103 let ssh_user = if ssh_user.is_empty() {
1104 DEFAULT_AWS_SSH_USER.to_owned()
1105 } else {
1106 ssh_user
1107 };
1108 let identity_file = prompt(input, output, "SSH identity file (optional): ")?;
1109
1110 Ok(Some(AwsTargetInput {
1111 launch_template,
1112 region,
1113 ssh_user,
1114 identity_file: (!identity_file.is_empty()).then(|| PathBuf::from(identity_file)),
1115 }))
1116}
1117
1118fn prompt_ssh_target(
1123 input: &mut impl SetupPrompter,
1124 output: &mut impl Write,
1125 aliases: &[String],
1126 configured: &BTreeMap<String, TargetTemplate>,
1127) -> Result<Option<SshTargetInput>> {
1128 if aliases.is_empty() {
1129 writeln!(
1130 output,
1131 "SSH: no host aliases found in ~/.ssh/config; skipping the SSH target."
1132 )?;
1133 return Ok(None);
1134 }
1135 writeln!(output, "SSH: hosts found in ~/.ssh/config:")?;
1136 for (index, alias) in aliases.iter().enumerate() {
1137 writeln!(output, " {}) {alias}", index + 1)?;
1138 }
1139 let answer = prompt(input, output, "Add an SSH target? [y/N]: ")?;
1140 if !matches!(answer.to_ascii_lowercase().as_str(), "y" | "yes") {
1141 return Ok(None);
1142 }
1143
1144 let choice = prompt(
1145 input,
1146 output,
1147 &format!("Host number 1-{} or a host name: ", aliases.len()),
1148 )?;
1149 let host = match choice.parse::<usize>() {
1150 Ok(index) if (1..=aliases.len()).contains(&index) => aliases[index - 1].clone(),
1151 _ if !choice.is_empty() => choice,
1152 _ => {
1153 writeln!(output, "A host is required; skipping the SSH target.")?;
1154 return Ok(None);
1155 }
1156 };
1157
1158 let kind = loop {
1159 let runtime = prompt(
1160 input,
1161 output,
1162 "Container runtime on that host, podman, docker, or bare [podman]: ",
1163 )?;
1164 let runtime = runtime.to_ascii_lowercase();
1165 if matches!(runtime.as_str(), "n" | "no" | "bare") {
1166 let permissions = loop {
1167 let mode = prompt(
1168 input,
1169 output,
1170 "Raw-host permissions, guardian or yolo [guardian]: ",
1171 )?;
1172 match mode.to_ascii_lowercase().as_str() {
1173 "" | "guardian" => break PermissionMode::Guardian,
1174 "yolo" => break PermissionMode::Yolo,
1175 _ => writeln!(output, "Permissions must be `guardian` or `yolo`.")?,
1176 }
1177 };
1178 break SshTargetKind::Bare { permissions };
1179 }
1180 let kind = if matches!(runtime.as_str(), "" | "y" | "yes" | "podman") {
1181 SshTargetKind::Podman {
1182 image: String::new(),
1183 }
1184 } else if runtime == "docker" {
1185 SshTargetKind::Docker {
1186 image: String::new(),
1187 }
1188 } else {
1189 writeln!(
1190 output,
1191 "Runtime must be `podman`, `docker`, or `bare`; please choose again."
1192 )?;
1193 continue;
1194 };
1195 let image = prompt(
1196 input,
1197 output,
1198 &format!("Container image [{DEFAULT_IMAGE}]: "),
1199 )?;
1200 let image = if image.is_empty() {
1201 DEFAULT_IMAGE.to_owned()
1202 } else {
1203 image
1204 };
1205 break match kind {
1206 SshTargetKind::Podman { .. } => SshTargetKind::Podman { image },
1207 SshTargetKind::Docker { .. } => SshTargetKind::Docker { image },
1208 SshTargetKind::Bare { .. } => unreachable!("bare runtime returned above"),
1209 };
1210 };
1211
1212 let Some(name) = prompt_ssh_target_name(input, output, &host, configured)? else {
1213 return Ok(None);
1214 };
1215
1216 Ok(Some(SshTargetInput { name, host, kind }))
1217}
1218
1219fn prompt_ssh_target_name(
1226 input: &mut impl SetupPrompter,
1227 output: &mut impl Write,
1228 host: &str,
1229 configured: &BTreeMap<String, TargetTemplate>,
1230) -> Result<Option<String>> {
1231 loop {
1232 let Some(answer) = prompt_line(input, output, &format!("Target name [{host}]: "))? else {
1233 writeln!(output, "Input ended; skipping the SSH target.")?;
1234 return Ok(None);
1235 };
1236 let name = if answer.is_empty() {
1237 host.to_owned()
1238 } else {
1239 answer
1240 };
1241 if let Err(error) = validate_id("target", &name) {
1242 writeln!(output, "{error}")?;
1243 continue;
1244 }
1245 if configured.contains_key(&name) {
1246 writeln!(
1247 output,
1248 "Target {name} is already configured; choose another name."
1249 )?;
1250 continue;
1251 }
1252 return Ok(Some(name));
1253 }
1254}
1255
1256fn smoke_failure_check(runtime: RuntimeKind, image: &str, error: &anyhow::Error) -> DoctorCheck {
1259 let scope = match runtime {
1260 RuntimeKind::Docker => "Disposable run/exec/remove and OverlayFS attachment smoke test",
1261 RuntimeKind::Podman | RuntimeKind::AppleContainer => {
1262 "Disposable run/exec/remove smoke test"
1263 }
1264 };
1265 DoctorCheck::fixable(
1266 format!("runtime.{}.smoke", runtime.id()),
1267 format!("{} smoke test", runtime.label()),
1268 format!("{scope} failed for image {image}: {error:#}"),
1269 format!(
1270 "Fix the configured image or the {} runtime, then run `mj doctor --smoke` again.",
1271 runtime.label()
1272 ),
1273 )
1274}
1275
1276fn write_doctor_report(
1282 output: &mut impl Write,
1283 config_path: &Path,
1284 executor: &impl CommandExecutor,
1285 extra: Vec<DoctorCheck>,
1286) -> Result<()> {
1287 writeln!(output)?;
1288 writeln!(output, "Running `mj doctor` checks on the new config...")?;
1289 let mut checks = run_with_config_path(
1290 config_path,
1291 executor,
1292 current_apple_platform(executor),
1293 DoctorOptions { smoke: false },
1294 );
1295 checks.extend(extra);
1296 render_human(&checks, output)?;
1297 if all_ready(&checks) {
1298 writeln!(output, "Every check is ready.")?;
1299 } else {
1300 writeln!(
1301 output,
1302 "Apply the remediations above, then rerun `mj doctor`."
1303 )?;
1304 }
1305 Ok(())
1306}
1307
1308fn prompt(input: &mut impl SetupPrompter, output: &mut impl Write, label: &str) -> Result<String> {
1309 Ok(prompt_line(input, output, label)?.unwrap_or_default())
1310}
1311
1312fn prompt_line(
1318 input: &mut impl SetupPrompter,
1319 output: &mut impl Write,
1320 label: &str,
1321) -> Result<Option<String>> {
1322 input.read_prompt(output, label)
1323}
1324
1325trait SetupPrompter {
1326 fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>>;
1327}
1328
1329impl<R: BufRead> SetupPrompter for R {
1330 fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>> {
1331 write!(output, "{label}")?;
1332 output.flush()?;
1333 let mut answer = String::new();
1334 let read = self.read_line(&mut answer).context("read setup response")?;
1335 Ok((read > 0).then(|| answer.trim().to_owned()))
1336 }
1337}
1338
1339#[derive(Default)]
1340struct ReadlinePrompter(crate::readline::LineReader);
1341
1342impl SetupPrompter for ReadlinePrompter {
1343 fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>> {
1344 output.flush()?;
1345 self.0.read_line(label).context("read setup response")
1346 }
1347}
1348
1349fn write_summary(
1350 output: &mut impl Write,
1351 config_path: &Path,
1352 config: &Config,
1353 runtimes: &[(RuntimeKind, String)],
1354) -> Result<()> {
1355 writeln!(output, "Mjolnir will add to {}:", config_path.display())?;
1356 writeln!(output, " {} profile(s)", config.profiles.len())?;
1357 writeln!(output, " {} bundle(s)", config.bundles.len())?;
1358 if config
1359 .targets
1360 .values()
1361 .any(|target| matches!(target, TargetTemplate::LocalBare))
1362 {
1363 writeln!(
1364 output,
1365 " raw localhost target using configured harness homes directly"
1366 )?;
1367 }
1368 for (runtime, image) in runtimes {
1369 writeln!(output, " {} target using {image}", runtime.label())?;
1370 }
1371 for id in config.targets.keys() {
1372 writeln!(output, " target id: {id}")?;
1373 }
1374 if let Some(TargetTemplate::AwsEc2 {
1375 launch_template,
1376 region,
1377 ..
1378 }) = config.targets.get(AWS_TARGET_ID)
1379 {
1380 writeln!(
1381 output,
1382 " AWS EC2 target using launch template {launch_template} in {region}"
1383 )?;
1384 }
1385 for (id, target) in &config.targets {
1386 match target {
1387 TargetTemplate::SshBare { ssh, .. } => {
1388 writeln!(output, " SSH target {id} on {} (no container)", ssh.host)?;
1389 }
1390 TargetTemplate::SshPodman { ssh, container, .. } => {
1391 writeln!(
1392 output,
1393 " SSH target {id} on {} using Podman image {}",
1394 ssh.host, container.image
1395 )?;
1396 }
1397 TargetTemplate::SshDocker { ssh, container } => {
1398 writeln!(
1399 output,
1400 " SSH target {id} on {} using Docker image {}",
1401 ssh.host, container.image
1402 )?;
1403 }
1404 _ => {}
1405 }
1406 }
1407 if config_path.exists() {
1408 writeln!(
1409 output,
1410 " Existing profiles, bundles, targets, and preferences will be preserved."
1411 )?;
1412 }
1413 Ok(())
1414}
1415
1416fn smoke_target(runtime: RuntimeKind, image: &str) -> RuntimeTargetTemplate {
1417 let container = RuntimeContainerTemplate {
1418 build_cache: None,
1419 image: image.to_owned(),
1420 pull_policy: Default::default(),
1421 extra_run_args: vec![],
1422 workspace_storage: Default::default(),
1423 };
1424 match runtime {
1425 RuntimeKind::Podman => RuntimeTargetTemplate::LocalPodman(container),
1426 RuntimeKind::Docker => RuntimeTargetTemplate::LocalDocker(container),
1427 RuntimeKind::AppleContainer => RuntimeTargetTemplate::AppleContainer(container),
1428 }
1429}
1430
1431fn run_smoke_test(
1432 output: &mut impl Write,
1433 target: &RuntimeTargetTemplate,
1434 executor: &impl CommandExecutor,
1435) -> Result<()> {
1436 let smoke_id = format!(
1437 "setup-{}-{:x}",
1438 std::process::id(),
1439 SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos()
1440 );
1441 let description = match target {
1442 RuntimeTargetTemplate::LocalDocker(_) => {
1443 "Smoke test: verifying a disposable container and writable OverlayFS attachment..."
1444 }
1445 _ => "Smoke test: verifying a disposable container...",
1446 };
1447 writeln!(output, "{description}")?;
1448 run_setup_smoke_test(target, &smoke_id, executor)
1449}
1450
1451#[cfg(test)]
1452mod tests;