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