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 {
772 PathBuf::from(mj_core::config::DEFAULT_WORKSPACE_PREFIX)
773}
774
775fn config_id(value: &str) -> String {
776 let mut id = value
777 .chars()
778 .filter(|character| {
779 character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
780 })
781 .take(64)
782 .collect::<String>();
783 if id.is_empty() || matches!(id.as_str(), "." | "..") {
784 id = "repository".to_owned();
785 }
786 id
787}
788
789pub fn run_setup_dialog_with(
796 input: &mut impl BufRead,
797 output: &mut impl Write,
798 config_path: &Path,
799 discovery: &SetupDiscovery,
800 smoke_executor: &impl CommandExecutor,
801 probe_executor: &impl CommandExecutor,
802) -> Result<SetupOutcome> {
803 run_setup_dialog_inner(
804 input,
805 output,
806 config_path,
807 discovery,
808 smoke_executor,
809 probe_executor,
810 )
811}
812
813fn run_setup_dialog_inner(
814 input: &mut impl SetupPrompter,
815 output: &mut impl Write,
816 config_path: &Path,
817 discovery: &SetupDiscovery,
818 smoke_executor: &impl CommandExecutor,
819 probe_executor: &impl CommandExecutor,
820) -> Result<SetupOutcome> {
821 let existing = Config::load_from(config_path)?;
822 writeln!(output, "Welcome to Mjolnir setup.")?;
823 writeln!(output)?;
824 write_discovered_homes(output, &discovery.homes)?;
825 write_repository(output, discovery.repository.as_ref())?;
826 write_runtimes(output, &discovery.runtimes)?;
827
828 let runtimes = if discovery.runtimes.iter().any(|runtime| runtime.usable) {
829 let image = prompt(
830 input,
831 output,
832 &format!("Container image [{DEFAULT_IMAGE}]: "),
833 )?;
834 let image = if image.is_empty() {
835 DEFAULT_IMAGE.to_owned()
836 } else {
837 image
838 };
839 discovery
840 .runtimes
841 .iter()
842 .filter(|runtime| runtime.usable)
843 .map(|runtime| (runtime.kind, image.clone()))
844 .collect::<Vec<_>>()
845 } else {
846 writeln!(
847 output,
848 "No usable container runtime found; raw localhost will still be configured."
849 )?;
850 Vec::new()
851 };
852 let aws = prompt_aws_target(input, output, discovery.aws.as_ref())?;
853 let runtime_choices = runtimes
854 .iter()
855 .map(|(runtime, image)| (*runtime, image.as_str()))
856 .collect::<Vec<_>>();
857 let configured = build_config_with_runtimes(
860 &discovery.homes,
861 discovery.repository.as_ref(),
862 &runtime_choices,
863 aws.as_ref(),
864 None,
865 );
866 let ssh = prompt_ssh_target(input, output, &discovery.ssh_hosts, &configured.targets)?;
867 let config = build_config_with_runtimes(
868 &discovery.homes,
869 discovery.repository.as_ref(),
870 &runtime_choices,
871 aws.as_ref(),
872 ssh.as_ref(),
873 );
874 config.validate()?;
875 let additions = reconcile_setup(input, output, &existing, config)?;
876 let runtimes = runtimes
877 .into_iter()
878 .filter(|(runtime, image)| {
879 let (_, target) = local_runtime_target(*runtime, image);
880 additions.targets.values().any(|added| added == &target)
881 })
882 .collect::<Vec<_>>();
883
884 writeln!(output)?;
885 write_summary(output, config_path, &additions, &runtimes)?;
886 let confirmation = prompt(input, output, "Write this configuration? [y/N]: ")?;
887 if !matches!(confirmation.to_ascii_lowercase().as_str(), "y" | "yes") {
888 writeln!(output, "Setup cancelled.")?;
889 return Ok(SetupOutcome::Cancelled);
890 }
891
892 writeln!(output, "Writing {}...", config_path.display())?;
893 Config::update_to(config_path, |latest| {
894 apply_setup_additions(latest, &additions)
895 })?;
896 let smoke_failures = runtimes
900 .iter()
901 .filter_map(|(runtime, image)| {
902 let target = smoke_target(*runtime, image);
903 run_smoke_test(output, &target, smoke_executor)
904 .err()
905 .map(|error| smoke_failure_check(*runtime, image, &error))
906 })
907 .collect();
908 write_doctor_report(output, config_path, probe_executor, smoke_failures)?;
909 writeln!(
910 output,
911 "Advanced users can edit TOML for extra profiles, virtual monorepos, SSH, and AWS."
912 )?;
913 writeln!(
914 output,
915 "Run `mj` to open Mjolnir, then press n in the Sessions pane to start your first session."
916 )?;
917 Ok(SetupOutcome::Written)
918}
919
920fn reconcile_setup(
922 input: &mut impl SetupPrompter,
923 output: &mut impl Write,
924 existing: &Config,
925 discovered: Config,
926) -> Result<Config> {
927 let mut additions = existing.setup_additions(&discovered);
928 for id in additions.profiles.keys() {
929 writeln!(output, "Adding discovered profile {id}.")?;
930 }
931 for id in additions.bundles.keys() {
932 writeln!(output, "Adding repository bundle {id}.")?;
933 }
934 for (id, target) in &discovered.targets {
935 if !existing.targets.contains_key(id) {
936 continue;
937 }
938 let alternate = additions
939 .targets
940 .iter()
941 .find(|(_, added)| *added == target)
942 .map(|(id, _)| id.clone());
943 let Some(alternate) = alternate else { continue };
944 writeln!(
945 output,
946 "Target {id} already has different settings. Existing sessions will keep using it."
947 )?;
948 let answer = prompt(
949 input,
950 output,
951 &format!("Keep {id}, or add the discovered settings as {alternate}? [K/a]: "),
952 )?;
953 if !matches!(answer.to_ascii_lowercase().as_str(), "a" | "add") {
954 additions.targets.remove(&alternate);
955 writeln!(
956 output,
957 "Keeping target {id}; discovered settings were not added."
958 )?;
959 }
960 }
961 Ok(additions)
962}
963
964fn apply_setup_additions(latest: &mut Config, additions: &Config) -> Result<()> {
965 fn add<T: Clone>(
966 section: &str,
967 latest: &mut BTreeMap<String, T>,
968 additions: &BTreeMap<String, T>,
969 same: impl Fn(&T, &T) -> bool,
970 ) -> Result<()> {
971 for (id, value) in additions {
972 if latest.values().any(|existing| same(existing, value)) {
973 continue;
974 }
975 ensure!(
976 !latest.contains_key(id),
977 "{section} {id:?} changed while setup was open; no configuration was written. Rerun mj setup to review the current settings"
978 );
979 latest.insert(id.clone(), value.clone());
980 }
981 Ok(())
982 }
983 add(
984 "profile",
985 &mut latest.profiles,
986 &additions.profiles,
987 HarnessProfile::same_installation,
988 )?;
989 add(
990 "bundle",
991 &mut latest.bundles,
992 &additions.bundles,
993 PartialEq::eq,
994 )?;
995 add(
996 "target",
997 &mut latest.targets,
998 &additions.targets,
999 PartialEq::eq,
1000 )?;
1001 latest.validate()
1002}
1003
1004fn write_discovered_homes(output: &mut impl Write, homes: &[DiscoveredHome]) -> Result<()> {
1005 writeln!(output, "Harness homes:")?;
1006 if homes.is_empty() {
1007 writeln!(
1008 output,
1009 " No existing Codex, Claude Code, Kimi Code, or Grok Build homes found."
1010 )?;
1011 }
1012 for home in homes {
1013 let authentication = if home.authenticated {
1014 "authenticated"
1015 } else {
1016 "not authenticated"
1017 };
1018 writeln!(
1019 output,
1020 " {}: {} ({authentication}){}",
1021 home.kind.display_name(),
1022 home.path.display(),
1023 match home.kind.unsandboxed_guardian_warning() {
1024 Some(warning) => format!(" — {warning}"),
1025 None => String::new(),
1026 }
1027 )?;
1028 }
1029 Ok(())
1030}
1031
1032fn write_repository(output: &mut impl Write, repository: Option<&GithubRepository>) -> Result<()> {
1033 match repository {
1034 Some(repository) => writeln!(
1035 output,
1036 "GitHub origin: {} (a one-repository bundle will be created)",
1037 repository.source()
1038 )?,
1039 None => writeln!(
1040 output,
1041 "GitHub origin: none detected in the current directory."
1042 )?,
1043 }
1044 Ok(())
1045}
1046
1047fn write_runtimes(output: &mut impl Write, runtimes: &[RuntimeProbe]) -> Result<()> {
1048 writeln!(output, "Local runtimes:")?;
1049 for runtime in runtimes {
1050 let state = if runtime.usable {
1051 "usable"
1052 } else {
1053 "unavailable"
1054 };
1055 if runtime.detail.is_empty() {
1056 writeln!(output, " {}: {state}", runtime.kind.label())?;
1057 } else {
1058 writeln!(
1059 output,
1060 " {}: {state} ({})",
1061 runtime.kind.label(),
1062 runtime.detail
1063 )?;
1064 }
1065 if let Some(remediation) = &runtime.remediation {
1066 writeln!(output, " remediation: {remediation}")?;
1067 }
1068 }
1069 Ok(())
1070}
1071
1072fn prompt_aws_target(
1075 input: &mut impl SetupPrompter,
1076 output: &mut impl Write,
1077 account: Option<&AwsAccount>,
1078) -> Result<Option<AwsTargetInput>> {
1079 let Some(account) = account else {
1080 writeln!(
1081 output,
1082 "AWS: no working `aws` CLI credentials found; skipping the AWS target."
1083 )?;
1084 return Ok(None);
1085 };
1086 writeln!(
1087 output,
1088 "AWS: credentials are valid for account {} ({}).",
1089 account.account, account.arn
1090 )?;
1091 let answer = prompt(input, output, "Add an AWS EC2 target? [y/N]: ")?;
1092 if !matches!(answer.to_ascii_lowercase().as_str(), "y" | "yes") {
1093 return Ok(None);
1094 }
1095
1096 let launch_template = prompt(input, output, "Launch template name: ")?;
1097 if launch_template.is_empty() {
1098 writeln!(
1099 output,
1100 "A launch template name is required; skipping the AWS target."
1101 )?;
1102 return Ok(None);
1103 }
1104
1105 let region_label = match &account.region {
1106 Some(region) => format!("Region [{region}]: "),
1107 None => "Region: ".to_owned(),
1108 };
1109 let region = prompt(input, output, ®ion_label)?;
1110 let region = if region.is_empty() {
1111 match &account.region {
1112 Some(region) => region.clone(),
1113 None => {
1114 writeln!(output, "A region is required; skipping the AWS target.")?;
1115 return Ok(None);
1116 }
1117 }
1118 } else {
1119 region
1120 };
1121
1122 let ssh_user = prompt(
1123 input,
1124 output,
1125 &format!("SSH user [{DEFAULT_AWS_SSH_USER}]: "),
1126 )?;
1127 let ssh_user = if ssh_user.is_empty() {
1128 DEFAULT_AWS_SSH_USER.to_owned()
1129 } else {
1130 ssh_user
1131 };
1132 let identity_file = prompt(input, output, "SSH identity file (optional): ")?;
1133
1134 Ok(Some(AwsTargetInput {
1135 launch_template,
1136 region,
1137 ssh_user,
1138 identity_file: (!identity_file.is_empty()).then(|| PathBuf::from(identity_file)),
1139 }))
1140}
1141
1142fn prompt_ssh_target(
1147 input: &mut impl SetupPrompter,
1148 output: &mut impl Write,
1149 aliases: &[String],
1150 configured: &BTreeMap<String, TargetTemplate>,
1151) -> Result<Option<SshTargetInput>> {
1152 if aliases.is_empty() {
1153 writeln!(
1154 output,
1155 "SSH: no host aliases found in ~/.ssh/config; skipping the SSH target."
1156 )?;
1157 return Ok(None);
1158 }
1159 writeln!(output, "SSH: hosts found in ~/.ssh/config:")?;
1160 for (index, alias) in aliases.iter().enumerate() {
1161 writeln!(output, " {}) {alias}", index + 1)?;
1162 }
1163 let answer = prompt(input, output, "Add an SSH target? [y/N]: ")?;
1164 if !matches!(answer.to_ascii_lowercase().as_str(), "y" | "yes") {
1165 return Ok(None);
1166 }
1167
1168 let choice = prompt(
1169 input,
1170 output,
1171 &format!("Host number 1-{} or a host name: ", aliases.len()),
1172 )?;
1173 let host = match choice.parse::<usize>() {
1174 Ok(index) if (1..=aliases.len()).contains(&index) => aliases[index - 1].clone(),
1175 _ if !choice.is_empty() => choice,
1176 _ => {
1177 writeln!(output, "A host is required; skipping the SSH target.")?;
1178 return Ok(None);
1179 }
1180 };
1181
1182 let kind = loop {
1183 let runtime = prompt(
1184 input,
1185 output,
1186 "Container runtime on that host, podman, docker, or bare [podman]: ",
1187 )?;
1188 let runtime = runtime.to_ascii_lowercase();
1189 if matches!(runtime.as_str(), "n" | "no" | "bare") {
1190 let permissions = loop {
1191 let mode = prompt(
1192 input,
1193 output,
1194 "Raw-host permissions, guardian or yolo [guardian]: ",
1195 )?;
1196 match mode.to_ascii_lowercase().as_str() {
1197 "" | "guardian" => break PermissionMode::Guardian,
1198 "yolo" => break PermissionMode::Yolo,
1199 _ => writeln!(output, "Permissions must be `guardian` or `yolo`.")?,
1200 }
1201 };
1202 break SshTargetKind::Bare { permissions };
1203 }
1204 let kind = if matches!(runtime.as_str(), "" | "y" | "yes" | "podman") {
1205 SshTargetKind::Podman {
1206 image: String::new(),
1207 }
1208 } else if runtime == "docker" {
1209 SshTargetKind::Docker {
1210 image: String::new(),
1211 }
1212 } else {
1213 writeln!(
1214 output,
1215 "Runtime must be `podman`, `docker`, or `bare`; please choose again."
1216 )?;
1217 continue;
1218 };
1219 let image = prompt(
1220 input,
1221 output,
1222 &format!("Container image [{DEFAULT_IMAGE}]: "),
1223 )?;
1224 let image = if image.is_empty() {
1225 DEFAULT_IMAGE.to_owned()
1226 } else {
1227 image
1228 };
1229 break match kind {
1230 SshTargetKind::Podman { .. } => SshTargetKind::Podman { image },
1231 SshTargetKind::Docker { .. } => SshTargetKind::Docker { image },
1232 SshTargetKind::Bare { .. } => unreachable!("bare runtime returned above"),
1233 };
1234 };
1235
1236 let Some(name) = prompt_ssh_target_name(input, output, &host, configured)? else {
1237 return Ok(None);
1238 };
1239
1240 Ok(Some(SshTargetInput { name, host, kind }))
1241}
1242
1243fn prompt_ssh_target_name(
1250 input: &mut impl SetupPrompter,
1251 output: &mut impl Write,
1252 host: &str,
1253 configured: &BTreeMap<String, TargetTemplate>,
1254) -> Result<Option<String>> {
1255 loop {
1256 let Some(answer) = prompt_line(input, output, &format!("Target name [{host}]: "))? else {
1257 writeln!(output, "Input ended; skipping the SSH target.")?;
1258 return Ok(None);
1259 };
1260 let name = if answer.is_empty() {
1261 host.to_owned()
1262 } else {
1263 answer
1264 };
1265 if let Err(error) = validate_id("target", &name) {
1266 writeln!(output, "{error}")?;
1267 continue;
1268 }
1269 if configured.contains_key(&name) {
1270 writeln!(
1271 output,
1272 "Target {name} is already configured; choose another name."
1273 )?;
1274 continue;
1275 }
1276 return Ok(Some(name));
1277 }
1278}
1279
1280fn smoke_failure_check(runtime: RuntimeKind, image: &str, error: &anyhow::Error) -> DoctorCheck {
1283 let scope = match runtime {
1284 RuntimeKind::Docker => "Disposable run/exec/remove and OverlayFS attachment smoke test",
1285 RuntimeKind::Podman | RuntimeKind::AppleContainer => {
1286 "Disposable run/exec/remove smoke test"
1287 }
1288 };
1289 DoctorCheck::fixable(
1290 format!("runtime.{}.smoke", runtime.id()),
1291 format!("{} smoke test", runtime.label()),
1292 format!("{scope} failed for image {image}: {error:#}"),
1293 format!(
1294 "Fix the configured image or the {} runtime, then run `mj doctor --smoke` again.",
1295 runtime.label()
1296 ),
1297 )
1298}
1299
1300fn write_doctor_report(
1306 output: &mut impl Write,
1307 config_path: &Path,
1308 executor: &impl CommandExecutor,
1309 extra: Vec<DoctorCheck>,
1310) -> Result<()> {
1311 writeln!(output)?;
1312 writeln!(output, "Running `mj doctor` checks on the new config...")?;
1313 let mut checks = run_with_config_path(
1314 config_path,
1315 executor,
1316 current_apple_platform(executor),
1317 DoctorOptions { smoke: false },
1318 );
1319 checks.extend(extra);
1320 render_human(&checks, output)?;
1321 if all_ready(&checks) {
1322 writeln!(output, "Every check is ready.")?;
1323 } else {
1324 writeln!(
1325 output,
1326 "Apply the remediations above, then rerun `mj doctor`."
1327 )?;
1328 }
1329 Ok(())
1330}
1331
1332fn prompt(input: &mut impl SetupPrompter, output: &mut impl Write, label: &str) -> Result<String> {
1333 Ok(prompt_line(input, output, label)?.unwrap_or_default())
1334}
1335
1336fn prompt_line(
1342 input: &mut impl SetupPrompter,
1343 output: &mut impl Write,
1344 label: &str,
1345) -> Result<Option<String>> {
1346 input.read_prompt(output, label)
1347}
1348
1349trait SetupPrompter {
1350 fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>>;
1351}
1352
1353impl<R: BufRead> SetupPrompter for R {
1354 fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>> {
1355 write!(output, "{label}")?;
1356 output.flush()?;
1357 let mut answer = String::new();
1358 let read = self.read_line(&mut answer).context("read setup response")?;
1359 Ok((read > 0).then(|| answer.trim().to_owned()))
1360 }
1361}
1362
1363#[derive(Default)]
1364struct ReadlinePrompter(crate::readline::LineReader);
1365
1366impl SetupPrompter for ReadlinePrompter {
1367 fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>> {
1368 output.flush()?;
1369 self.0.read_line(label).context("read setup response")
1370 }
1371}
1372
1373fn write_summary(
1374 output: &mut impl Write,
1375 config_path: &Path,
1376 config: &Config,
1377 runtimes: &[(RuntimeKind, String)],
1378) -> Result<()> {
1379 writeln!(output, "Mjolnir will add to {}:", config_path.display())?;
1380 let counted = mj_core::text::counted;
1381 writeln!(
1382 output,
1383 " {}",
1384 counted(config.profiles.len(), "profile", "profiles")
1385 )?;
1386 writeln!(
1387 output,
1388 " {}",
1389 counted(config.bundles.len(), "bundle", "bundles")
1390 )?;
1391 if config
1392 .targets
1393 .values()
1394 .any(|target| matches!(target, TargetTemplate::LocalBare))
1395 {
1396 writeln!(
1397 output,
1398 " raw localhost target using configured harness homes directly"
1399 )?;
1400 }
1401 for (runtime, image) in runtimes {
1402 writeln!(output, " {} target using {image}", runtime.label())?;
1403 }
1404 for id in config.targets.keys() {
1405 writeln!(output, " target id: {id}")?;
1406 }
1407 if let Some(TargetTemplate::AwsEc2 {
1408 launch_template,
1409 region,
1410 ..
1411 }) = config.targets.get(AWS_TARGET_ID)
1412 {
1413 writeln!(
1414 output,
1415 " AWS EC2 target using launch template {launch_template} in {region}"
1416 )?;
1417 }
1418 for (id, target) in &config.targets {
1419 match target {
1420 TargetTemplate::SshBare { ssh, .. } => {
1421 writeln!(output, " SSH target {id} on {} (no container)", ssh.host)?;
1422 }
1423 TargetTemplate::SshPodman { ssh, container, .. } => {
1424 writeln!(
1425 output,
1426 " SSH target {id} on {} using Podman image {}",
1427 ssh.host, container.image
1428 )?;
1429 }
1430 TargetTemplate::SshDocker { ssh, container } => {
1431 writeln!(
1432 output,
1433 " SSH target {id} on {} using Docker image {}",
1434 ssh.host, container.image
1435 )?;
1436 }
1437 _ => {}
1438 }
1439 }
1440 if config_path.exists() {
1441 writeln!(
1442 output,
1443 " Existing profiles, bundles, targets, and preferences will be preserved."
1444 )?;
1445 }
1446 Ok(())
1447}
1448
1449fn smoke_target(runtime: RuntimeKind, image: &str) -> RuntimeTargetTemplate {
1450 let container = RuntimeContainerTemplate {
1451 build_cache: None,
1452 image: image.to_owned(),
1453 pull_policy: Default::default(),
1454 extra_run_args: vec![],
1455 workspace_storage: Default::default(),
1456 };
1457 match runtime {
1458 RuntimeKind::Podman => RuntimeTargetTemplate::LocalPodman(container),
1459 RuntimeKind::Docker => RuntimeTargetTemplate::LocalDocker(container),
1460 RuntimeKind::AppleContainer => RuntimeTargetTemplate::AppleContainer(container),
1461 }
1462}
1463
1464fn run_smoke_test(
1465 output: &mut impl Write,
1466 target: &RuntimeTargetTemplate,
1467 executor: &impl CommandExecutor,
1468) -> Result<()> {
1469 let smoke_id = format!(
1470 "setup-{}-{:x}",
1471 std::process::id(),
1472 SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos()
1473 );
1474 let description = match target {
1475 RuntimeTargetTemplate::LocalDocker(_) => {
1476 "Smoke test: verifying a disposable container and writable OverlayFS attachment..."
1477 }
1478 _ => "Smoke test: verifying a disposable container...",
1479 };
1480 writeln!(output, "{description}")?;
1481 if let Some(announcement) = smoke_download_announcement(target) {
1482 writeln!(output, "{announcement}")?;
1483 }
1484 let started = std::time::Instant::now();
1487 let result = run_setup_smoke_test(target, &smoke_id, executor);
1488 let took = mj_core::activity::describe_duration(
1489 u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
1490 );
1491 match &result {
1492 Ok(()) => writeln!(output, "Smoke test passed in {took}.")?,
1493 Err(_) => writeln!(
1494 output,
1495 "Smoke test failed after {took}; the checks below say what to fix."
1496 )?,
1497 }
1498 result
1499}
1500
1501const DEFAULT_IMAGE_DOWNLOAD_SIZE: &str = "about 2 GB";
1505
1506fn smoke_download_announcement(target: &RuntimeTargetTemplate) -> Option<String> {
1510 let (engine, container) = match target {
1511 RuntimeTargetTemplate::LocalPodman(container) => ("Podman", container),
1512 RuntimeTargetTemplate::LocalDocker(container) => ("Docker", container),
1513 RuntimeTargetTemplate::AppleContainer(container) => ("Apple container", container),
1514 _ => return None,
1515 };
1516 let image = &container.image;
1517 let size = if image == mj_core::config::DEFAULT_CONTAINER_IMAGE {
1518 format!(" ({DEFAULT_IMAGE_DOWNLOAD_SIZE})")
1519 } else {
1520 String::new()
1521 };
1522 Some(format!(
1523 "If {image} is not on this machine yet, {engine} downloads it first{size}. That can take several minutes, and nothing more is printed until it finishes."
1524 ))
1525}
1526
1527#[cfg(test)]
1528mod tests;