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