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::harness_authentication_marker;
21use mj_core::config::{
22 AwsAddressSource, Config, ContainerTemplate, HarnessKind, HarnessProfile, PermissionMode,
23 ProjectBundle, ProjectRepository, SshConnection, TargetTemplate, unique_config_id as unique_id,
24 validate_id,
25};
26
27const AWS_PROBE_TIMEOUT: Duration = Duration::from_secs(8);
30
31const DEFAULT_AWS_SSH_USER: &str = "ubuntu";
34const AWS_TARGET_ID: &str = "aws";
35
36pub use mj_client::target::DEFAULT_IMAGE;
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct DiscoveredHome {
44 pub kind: HarnessKind,
45 pub path: PathBuf,
46 pub authenticated: bool,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct GithubRepository {
51 pub owner: String,
52 pub repository: String,
53}
54
55impl GithubRepository {
56 fn source(&self) -> String {
57 format!("{}/{}", self.owner, self.repository)
58 }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum RuntimeKind {
63 Podman,
64 Docker,
65 AppleContainer,
66}
67
68impl RuntimeKind {
69 fn id(self) -> &'static str {
70 match self {
71 Self::Podman => "podman",
72 Self::Docker => "docker",
73 Self::AppleContainer => "apple-container",
74 }
75 }
76
77 fn label(self) -> &'static str {
78 match self {
79 Self::Podman => "Podman",
80 Self::Docker => "Docker",
81 Self::AppleContainer => "Apple container",
82 }
83 }
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct RuntimeProbe {
88 pub kind: RuntimeKind,
89 pub usable: bool,
90 pub detail: String,
91 pub remediation: Option<String>,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct AwsAccount {
99 pub account: String,
100 pub arn: String,
101 pub region: Option<String>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct AwsTargetInput {
108 pub launch_template: String,
109 pub region: String,
110 pub ssh_user: String,
111 pub identity_file: Option<PathBuf>,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
116pub enum SshTargetKind {
117 Bare { permissions: PermissionMode },
118 Podman { image: String },
119 Docker { image: String },
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct SshTargetInput {
125 pub name: String,
126 pub host: String,
127 pub kind: SshTargetKind,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct SetupDiscovery {
132 pub homes: Vec<DiscoveredHome>,
133 pub repository: Option<GithubRepository>,
134 pub runtimes: Vec<RuntimeProbe>,
135 pub aws: Option<AwsAccount>,
138 pub ssh_hosts: Vec<String>,
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum SetupOutcome {
145 Written,
146 Cancelled,
147}
148
149pub fn initialize_local_startup_config(config_path: &Path) -> Result<()> {
153 #[cfg(unix)]
154 {
155 let config = Config::load_from(config_path)?;
156 if config.is_unconfigured() && config.newer_config_version.is_none() {
157 let kind = HarnessKind::Codex;
158 let home = std::env::var_os(kind.home_env())
159 .map(|value| kind.home_from_environment(value))
160 .or_else(|| dirs::home_dir().map(|home| home.join(kind.default_home_leaf())))
161 .context("locate Codex home for the default local profile")?;
162 let home = std::path::absolute(home).context("resolve Codex profile home")?;
163 Config::update_to(config_path, |fresh| {
164 if fresh.is_unconfigured() {
165 configure_local_startup(fresh, home);
166 }
167 Ok(())
168 })?;
169 }
170 }
171 #[cfg(not(unix))]
173 let _ = config_path;
174 Ok(())
175}
176
177#[cfg(unix)]
178fn configure_local_startup(config: &mut Config, codex_home: PathBuf) {
179 config.profiles.insert(
180 "codex".into(),
181 HarnessProfile {
182 enabled: true,
183 kind: HarnessKind::Codex,
184 home: codex_home,
185 environment: BTreeMap::new(),
186 context_window_bytes: None,
187 },
188 );
189 config
190 .targets
191 .insert("localhost".into(), TargetTemplate::LocalBare);
192}
193
194pub fn run_setup_dialog(config_path: &Path) -> Result<SetupOutcome> {
196 let probes = probe_executor();
200 let discovery = discover_current(&probes);
201 let stdout = io::stdout();
202 let mut input = ReadlinePrompter::default();
203 run_setup_dialog_inner(
204 &mut input,
205 &mut stdout.lock(),
206 config_path,
207 &discovery,
208 &ProcessExecutor,
209 &probes,
210 )
211}
212
213pub fn discover_current(executor: &impl CommandExecutor) -> SetupDiscovery {
214 let home = dirs::home_dir();
215 let overrides = HarnessKind::ALL
216 .into_iter()
217 .filter_map(|kind| {
218 std::env::var_os(kind.home_env()).map(|path| (kind, kind.home_from_environment(path)))
219 })
220 .collect::<BTreeMap<_, _>>();
221 let mut homes =
222 discover_harness_homes_with_executor(home.as_deref(), overrides.clone(), executor);
223 discover_installed_harnesses(home.as_deref(), &overrides, &mut homes, executor);
224 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
225
226 SetupDiscovery {
227 homes,
228 repository: discover_github_repository(executor, &cwd),
229 runtimes: probe_local_runtimes(executor, cfg!(target_os = "macos")),
230 aws: detect_aws(&CancellableProcessExecutor::with_timeout(AWS_PROBE_TIMEOUT)),
231 ssh_hosts: discover_ssh_hosts(home.as_deref()),
232 }
233}
234
235pub fn discover_ssh_hosts(home: Option<&Path>) -> Vec<String> {
243 let Some(home) = home else {
244 return Vec::new();
245 };
246 let Ok(contents) = std::fs::read_to_string(home.join(".ssh").join("config")) else {
247 return Vec::new();
248 };
249 ssh_config_aliases(&contents)
250}
251
252pub fn ssh_config_aliases(contents: &str) -> Vec<String> {
257 let mut aliases: Vec<String> = Vec::new();
258 for line in contents.lines() {
259 let line = line.trim();
260 if line.is_empty() || line.starts_with('#') {
261 continue;
262 }
263 let Some((keyword, rest)) = line.split_once(char::is_whitespace) else {
264 continue;
265 };
266 if !keyword.eq_ignore_ascii_case("host") {
267 continue;
268 }
269 for alias in rest.split_whitespace() {
270 let alias = alias.trim_matches('"');
271 if alias.is_empty() || alias.contains(['*', '?', '!']) {
272 continue;
273 }
274 if !aliases.iter().any(|existing| existing == alias) {
275 aliases.push(alias.to_owned());
276 }
277 }
278 }
279 aliases
280}
281
282fn discover_installed_harnesses(
285 user_home: Option<&Path>,
286 overrides: &BTreeMap<HarnessKind, PathBuf>,
287 homes: &mut Vec<DiscoveredHome>,
288 executor: &impl CommandExecutor,
289) {
290 for kind in HarnessKind::ALL {
291 if homes.iter().any(|home| home.kind == kind) {
292 continue;
293 }
294 if kind == HarnessKind::Zcode {
299 continue;
300 }
301 let Some(home) = overrides
302 .get(&kind)
303 .cloned()
304 .or_else(|| user_home.map(|home| home.join(kind.default_home_leaf())))
305 else {
306 continue;
307 };
308 let profile = HarnessProfile {
309 enabled: true,
310 kind,
311 home: home.clone(),
312 environment: BTreeMap::new(),
313 context_window_bytes: None,
314 };
315 let (program, _) = mj_core::credentials::login_command(&profile);
316 let probe = CommandSpec::new(program, ["--version"])
317 .purpose("detect installed harness before first login");
318 match executor.execute(&probe) {
319 Ok(output) if output.status == 0 => homes.push(DiscoveredHome {
320 kind,
321 path: home,
322 authenticated: false,
323 }),
324 Ok(_) => {}
325 Err(error) => tracing::debug!(
326 harness = kind.id(),
327 "installation probe unavailable: {error:#}"
328 ),
329 }
330 }
331}
332
333pub fn discover_harness_homes(
334 home: Option<&Path>,
335 overrides: impl IntoIterator<Item = (HarnessKind, PathBuf)>,
336) -> Vec<DiscoveredHome> {
337 discover_harness_homes_with_executor(home, overrides, &probe_executor())
338}
339
340pub(crate) fn discover_harness_homes_with_executor(
341 home: Option<&Path>,
342 overrides: impl IntoIterator<Item = (HarnessKind, PathBuf)>,
343 executor: &impl CommandExecutor,
344) -> Vec<DiscoveredHome> {
345 let mut candidates = Vec::new();
346 if let Some(home) = home {
347 candidates.extend(
348 HarnessKind::ALL
349 .into_iter()
350 .map(|kind| (kind, home.join(kind.default_home_leaf()), true)),
351 );
352 }
353 candidates.extend(
354 overrides
355 .into_iter()
356 .map(|(kind, path)| (kind, path, false)),
357 );
358
359 let mut seen = BTreeSet::new();
360 candidates
361 .into_iter()
362 .filter(|(kind, path, _)| seen.insert((*kind, path.clone())) && path.is_dir())
363 .map(|(kind, path, is_default_home)| DiscoveredHome {
364 authenticated: harness_is_authenticated_with(kind, &path, is_default_home, executor),
365 kind,
366 path,
367 })
368 .collect()
369}
370
371pub fn harness_is_authenticated(kind: HarnessKind, home: &Path) -> bool {
372 harness_is_authenticated_with_executor(kind, home, &probe_executor())
373}
374
375pub(crate) fn harness_is_authenticated_with_executor(
376 kind: HarnessKind,
377 home: &Path,
378 executor: &impl CommandExecutor,
379) -> bool {
380 let is_default_home =
381 dirs::home_dir().is_some_and(|user_home| home == user_home.join(kind.default_home_leaf()));
382 harness_is_authenticated_with(kind, home, is_default_home, executor)
383}
384
385fn harness_is_authenticated_with(
386 kind: HarnessKind,
387 home: &Path,
388 is_default_home: bool,
389 executor: &impl CommandExecutor,
390) -> bool {
391 if harness_authentication_marker(kind, home).is_file()
392 || (kind == HarnessKind::Kimi && home.join("credentials").is_file())
393 {
394 return true;
395 }
396 if kind != HarnessKind::Claude {
397 return false;
398 }
399 if is_default_home && claude_keychain_reports_authenticated(executor) {
400 return true;
401 }
402 if !is_default_home && claude_cli_reports_authenticated(home, executor) {
403 return true;
404 }
405 false
406}
407
408fn claude_cli_reports_authenticated(home: &Path, executor: &impl CommandExecutor) -> bool {
412 let mut command = CommandSpec::new("claude", ["auth", "status", "--json"])
413 .purpose("check Claude Code authentication");
414 command.env.insert(
415 HarnessKind::Claude.home_env().to_owned(),
416 home.to_string_lossy().into_owned(),
417 );
418 let Ok(output) = executor.execute(&command) else {
419 return false;
420 };
421 if output.status != 0 {
422 return false;
423 }
424 serde_json::from_slice::<serde_json::Value>(&output.stdout)
425 .ok()
426 .and_then(|status| status.get("loggedIn").and_then(serde_json::Value::as_bool))
427 == Some(true)
428}
429
430#[cfg(target_os = "macos")]
434fn claude_keychain_reports_authenticated(executor: &impl CommandExecutor) -> bool {
435 let command = CommandSpec::new(
436 "security",
437 [
438 "find-generic-password",
439 "-s",
440 "Claude Code-credentials",
441 "-w",
442 ],
443 )
444 .purpose("check Claude Code authentication in the macOS Keychain");
445 let Ok(output) = executor.execute(&command) else {
446 return false;
447 };
448 output.status == 0 && claude_credentials_contain_login(&output.stdout)
449}
450
451#[cfg(not(target_os = "macos"))]
452fn claude_keychain_reports_authenticated(_executor: &impl CommandExecutor) -> bool {
453 false
454}
455
456#[cfg(any(target_os = "macos", test))]
457fn claude_credentials_contain_login(credentials: &[u8]) -> bool {
458 let Ok(document) = serde_json::from_slice::<serde_json::Value>(credentials) else {
459 return false;
460 };
461 [
462 "/claudeAiOauth/accessToken",
463 "/claudeAiOauth/refreshToken",
464 "/oauth/accessToken",
465 "/apiKey",
466 ]
467 .into_iter()
468 .any(|pointer| {
469 document
470 .pointer(pointer)
471 .and_then(serde_json::Value::as_str)
472 .is_some_and(|value| !value.trim().is_empty())
473 })
474}
475
476pub fn github_repository_from_origin(origin: &str) -> Option<GithubRepository> {
477 let origin = origin.trim();
478 let path = origin
479 .strip_prefix("https://github.com/")
480 .or_else(|| origin.strip_prefix("http://github.com/"))
481 .or_else(|| origin.strip_prefix("git@github.com:"))
482 .or_else(|| origin.strip_prefix("ssh://git@github.com/"))
483 .unwrap_or(origin);
486 let path = path.trim_end_matches(".git");
487 let mut parts = path.split('/');
488 let owner = parts.next()?;
489 let repository = parts.next()?;
490 if owner.is_empty()
491 || repository.is_empty()
492 || parts.next().is_some()
493 || owner.chars().any(char::is_whitespace)
494 || repository.chars().any(char::is_whitespace)
495 {
496 return None;
497 }
498 Some(GithubRepository {
499 owner: owner.to_owned(),
500 repository: repository.to_owned(),
501 })
502}
503
504fn discover_github_repository(
510 executor: &impl CommandExecutor,
511 cwd: &Path,
512) -> Option<GithubRepository> {
513 let command = CommandSpec::new(
514 "git",
515 [
516 "-C".to_owned(),
517 cwd.to_string_lossy().into_owned(),
518 "remote".to_owned(),
519 "get-url".to_owned(),
520 "origin".to_owned(),
521 ],
522 )
523 .purpose("detect the current repository's GitHub origin");
524 let output = executor.execute(&command).ok()?;
525 if output.status != 0 {
526 return None;
527 }
528 github_repository_from_origin(&String::from_utf8_lossy(&output.stdout))
529}
530
531pub fn probe_local_runtimes(executor: &impl CommandExecutor, is_macos: bool) -> Vec<RuntimeProbe> {
534 let mut probes = vec![
535 runtime_probe_from_check(RuntimeKind::Podman, local_podman_runtime_check(executor)),
536 runtime_probe_from_check(RuntimeKind::Docker, local_docker_runtime_check(executor)),
537 ];
538 if is_macos {
539 probes.push(runtime_probe_from_check(
540 RuntimeKind::AppleContainer,
541 apple_container_daemon_check(executor),
542 ));
543 }
544 probes
545}
546
547fn runtime_probe_from_check(kind: RuntimeKind, check: crate::doctor::DoctorCheck) -> RuntimeProbe {
548 RuntimeProbe {
549 kind,
550 usable: check.status == CheckStatus::Ready,
551 detail: check.detail,
552 remediation: check.remediation,
553 }
554}
555
556pub fn detect_aws(executor: &impl CommandExecutor) -> Option<AwsAccount> {
562 let identity = CommandSpec::new("aws", ["sts", "get-caller-identity", "--output", "json"])
563 .purpose("detect AWS credentials");
564 let output = executor.execute(&identity).ok()?;
565 if output.status != 0 {
566 return None;
567 }
568 let identity: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
569 let account = identity.get("Account")?.as_str()?.to_owned();
570 let arn = identity.get("Arn")?.as_str()?.to_owned();
571 Some(AwsAccount {
572 account,
573 arn,
574 region: configured_aws_region(executor),
575 })
576}
577
578fn configured_aws_region(executor: &impl CommandExecutor) -> Option<String> {
579 let command = CommandSpec::new("aws", ["configure", "get", "region"])
580 .purpose("read the default AWS region");
581 let output = executor.execute(&command).ok()?;
582 if output.status != 0 {
583 return None;
584 }
585 let region = String::from_utf8_lossy(&output.stdout).trim().to_owned();
586 (!region.is_empty()).then_some(region)
587}
588
589pub fn build_config(
590 homes: &[DiscoveredHome],
591 repository: Option<&GithubRepository>,
592 runtime: RuntimeKind,
593 image: &str,
594) -> Config {
595 build_config_with_runtime(homes, repository, Some((runtime, image)), None, None)
596}
597
598fn build_config_with_runtime(
599 homes: &[DiscoveredHome],
600 repository: Option<&GithubRepository>,
601 runtime: Option<(RuntimeKind, &str)>,
602 aws: Option<&AwsTargetInput>,
603 ssh: Option<&SshTargetInput>,
604) -> Config {
605 build_config_with_runtimes(
606 homes,
607 repository,
608 &runtime.into_iter().collect::<Vec<_>>(),
609 aws,
610 ssh,
611 )
612}
613
614fn build_config_with_runtimes(
615 homes: &[DiscoveredHome],
616 repository: Option<&GithubRepository>,
617 runtimes: &[(RuntimeKind, &str)],
618 aws: Option<&AwsTargetInput>,
619 ssh: Option<&SshTargetInput>,
620) -> Config {
621 let mut config = Config::default();
622 for home in homes {
623 let id = unique_id(&config.profiles, home.kind.id());
624 config.profiles.insert(
625 id,
626 HarnessProfile {
627 enabled: true,
628 kind: home.kind,
629 home: home.path.clone(),
630 environment: BTreeMap::new(),
631 context_window_bytes: None,
632 },
633 );
634 }
635
636 if let Some(repository) = repository {
637 let repository_id = config_id(&repository.repository);
638 config.bundles.insert(
639 "current-repository".to_owned(),
640 ProjectBundle {
641 primary_repo: repository_id.clone(),
642 repositories: vec![ProjectRepository {
643 id: repository_id.clone(),
644 github: Some(repository.source()),
645 local: None,
646 destination: PathBuf::from(repository_id),
647 git_ref: None,
648 }],
649 },
650 );
651 }
652
653 #[cfg(unix)]
654 config
655 .targets
656 .insert("localhost".to_owned(), TargetTemplate::LocalBare);
657 for (runtime, image) in runtimes {
658 let (target_id, target) = local_runtime_target(*runtime, image);
659 config.targets.insert(target_id.to_owned(), target);
660 }
661 if let Some(aws) = aws {
662 config.targets.insert(
663 AWS_TARGET_ID.to_owned(),
664 TargetTemplate::AwsEc2 {
665 aws_profile: None,
666 region: aws.region.clone(),
667 launch_template: aws.launch_template.clone(),
668 launch_template_version: None,
669 ssh_user: aws.ssh_user.clone(),
670 address_source: AwsAddressSource::default(),
671 identity_file: aws.identity_file.clone(),
672 ssh_args: vec![],
673 },
674 );
675 }
676 if let Some(ssh) = ssh {
677 let connection = SshConnection {
680 host: ssh.host.clone(),
681 user: None,
682 identity_file: None,
683 extra_args: vec![],
684 };
685 let target = match &ssh.kind {
686 SshTargetKind::Bare { permissions } => TargetTemplate::SshBare {
687 ssh: connection,
688 permissions: *permissions,
689 workspace_prefix: default_ssh_workspace_prefix(),
690 },
691 SshTargetKind::Podman { image } => TargetTemplate::SshPodman {
692 ssh: connection,
693 container: ContainerTemplate {
694 image: image.clone(),
695 pull_policy: Default::default(),
696 platform: None,
697 cpus: None,
698 memory: None,
699 environment: BTreeMap::new(),
700 workspace_storage: Default::default(),
701 },
702 },
703 SshTargetKind::Docker { image } => TargetTemplate::SshDocker {
704 ssh: connection,
705 container: ContainerTemplate {
706 image: image.clone(),
707 pull_policy: Default::default(),
708 platform: None,
709 cpus: None,
710 memory: None,
711 environment: BTreeMap::new(),
712 workspace_storage: Default::default(),
713 },
714 },
715 };
716 config
720 .targets
721 .insert(unique_id(&config.targets, &ssh.name), target);
722 }
723 config
724}
725
726pub fn local_runtime_target(runtime: RuntimeKind, image: &str) -> (&'static str, TargetTemplate) {
728 let container = ContainerTemplate {
729 image: image.trim().to_owned(),
730 pull_policy: Default::default(),
731 platform: None,
732 cpus: None,
733 memory: None,
734 environment: BTreeMap::new(),
735 workspace_storage: Default::default(),
736 };
737 match runtime {
738 RuntimeKind::Podman => ("podman", TargetTemplate::LocalPodman { container }),
739 RuntimeKind::Docker => ("docker", TargetTemplate::LocalDocker { container }),
740 RuntimeKind::AppleContainer => (
741 "apple-container",
742 TargetTemplate::AppleContainer { container },
743 ),
744 }
745}
746
747fn default_ssh_workspace_prefix() -> PathBuf {
749 PathBuf::from(".local/share/hel/workspaces")
750}
751
752fn config_id(value: &str) -> String {
753 let mut id = value
754 .chars()
755 .filter(|character| {
756 character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
757 })
758 .take(64)
759 .collect::<String>();
760 if id.is_empty() || matches!(id.as_str(), "." | "..") {
761 id = "repository".to_owned();
762 }
763 id
764}
765
766pub fn run_setup_dialog_with(
773 input: &mut impl BufRead,
774 output: &mut impl Write,
775 config_path: &Path,
776 discovery: &SetupDiscovery,
777 smoke_executor: &impl CommandExecutor,
778 probe_executor: &impl CommandExecutor,
779) -> Result<SetupOutcome> {
780 run_setup_dialog_inner(
781 input,
782 output,
783 config_path,
784 discovery,
785 smoke_executor,
786 probe_executor,
787 )
788}
789
790fn run_setup_dialog_inner(
791 input: &mut impl SetupPrompter,
792 output: &mut impl Write,
793 config_path: &Path,
794 discovery: &SetupDiscovery,
795 smoke_executor: &impl CommandExecutor,
796 probe_executor: &impl CommandExecutor,
797) -> Result<SetupOutcome> {
798 let existing = Config::load_from(config_path)?;
799 writeln!(output, "Welcome to Mjolnir setup.")?;
800 writeln!(output)?;
801 write_discovered_homes(output, &discovery.homes)?;
802 write_repository(output, discovery.repository.as_ref())?;
803 write_runtimes(output, &discovery.runtimes)?;
804
805 let runtimes = if discovery.runtimes.iter().any(|runtime| runtime.usable) {
806 let image = prompt(
807 input,
808 output,
809 &format!("Container image [{DEFAULT_IMAGE}]: "),
810 )?;
811 let image = if image.is_empty() {
812 DEFAULT_IMAGE.to_owned()
813 } else {
814 image
815 };
816 discovery
817 .runtimes
818 .iter()
819 .filter(|runtime| runtime.usable)
820 .map(|runtime| (runtime.kind, image.clone()))
821 .collect::<Vec<_>>()
822 } else {
823 writeln!(
824 output,
825 "No usable container runtime found; raw localhost will still be configured."
826 )?;
827 Vec::new()
828 };
829 let aws = prompt_aws_target(input, output, discovery.aws.as_ref())?;
830 let runtime_choices = runtimes
831 .iter()
832 .map(|(runtime, image)| (*runtime, image.as_str()))
833 .collect::<Vec<_>>();
834 let configured = build_config_with_runtimes(
837 &discovery.homes,
838 discovery.repository.as_ref(),
839 &runtime_choices,
840 aws.as_ref(),
841 None,
842 );
843 let ssh = prompt_ssh_target(input, output, &discovery.ssh_hosts, &configured.targets)?;
844 let config = build_config_with_runtimes(
845 &discovery.homes,
846 discovery.repository.as_ref(),
847 &runtime_choices,
848 aws.as_ref(),
849 ssh.as_ref(),
850 );
851 config.validate()?;
852 let additions = reconcile_setup(input, output, &existing, config)?;
853 let runtimes = runtimes
854 .into_iter()
855 .filter(|(runtime, image)| {
856 let (_, target) = local_runtime_target(*runtime, image);
857 additions.targets.values().any(|added| added == &target)
858 })
859 .collect::<Vec<_>>();
860
861 writeln!(output)?;
862 write_summary(output, config_path, &additions, &runtimes)?;
863 let confirmation = prompt(input, output, "Write this configuration? [y/N]: ")?;
864 if !matches!(confirmation.to_ascii_lowercase().as_str(), "y" | "yes") {
865 writeln!(output, "Setup cancelled.")?;
866 return Ok(SetupOutcome::Cancelled);
867 }
868
869 writeln!(output, "Writing {}...", config_path.display())?;
870 Config::update_to(config_path, |latest| {
871 apply_setup_additions(latest, &additions)
872 })?;
873 let smoke_failures = runtimes
877 .iter()
878 .filter_map(|(runtime, image)| {
879 let target = smoke_target(*runtime, image);
880 run_smoke_test(output, &target, smoke_executor)
881 .err()
882 .map(|error| smoke_failure_check(*runtime, image, &error))
883 })
884 .collect();
885 write_doctor_report(output, config_path, probe_executor, smoke_failures)?;
886 writeln!(
887 output,
888 "Advanced users can edit TOML for extra profiles, virtual monorepos, SSH, and AWS."
889 )?;
890 writeln!(output, "Press n to start your first session.")?;
891 Ok(SetupOutcome::Written)
892}
893
894fn reconcile_setup(
896 input: &mut impl SetupPrompter,
897 output: &mut impl Write,
898 existing: &Config,
899 discovered: Config,
900) -> Result<Config> {
901 let mut additions = existing.setup_additions(&discovered);
902 for id in additions.profiles.keys() {
903 writeln!(output, "Adding discovered profile {id}.")?;
904 }
905 for id in additions.bundles.keys() {
906 writeln!(output, "Adding repository bundle {id}.")?;
907 }
908 for (id, target) in &discovered.targets {
909 if !existing.targets.contains_key(id) {
910 continue;
911 }
912 let alternate = additions
913 .targets
914 .iter()
915 .find(|(_, added)| *added == target)
916 .map(|(id, _)| id.clone());
917 let Some(alternate) = alternate else { continue };
918 writeln!(
919 output,
920 "Target {id} already has different settings. Existing sessions will keep using it."
921 )?;
922 let answer = prompt(
923 input,
924 output,
925 &format!("Keep {id}, or add the discovered settings as {alternate}? [K/a]: "),
926 )?;
927 if !matches!(answer.to_ascii_lowercase().as_str(), "a" | "add") {
928 additions.targets.remove(&alternate);
929 writeln!(
930 output,
931 "Keeping target {id}; discovered settings were not added."
932 )?;
933 }
934 }
935 Ok(additions)
936}
937
938fn apply_setup_additions(latest: &mut Config, additions: &Config) -> Result<()> {
939 fn add<T: Clone>(
940 section: &str,
941 latest: &mut BTreeMap<String, T>,
942 additions: &BTreeMap<String, T>,
943 same: impl Fn(&T, &T) -> bool,
944 ) -> Result<()> {
945 for (id, value) in additions {
946 if latest.values().any(|existing| same(existing, value)) {
947 continue;
948 }
949 ensure!(
950 !latest.contains_key(id),
951 "{section} {id:?} changed while setup was open; no configuration was written. Rerun mj setup to review the current settings"
952 );
953 latest.insert(id.clone(), value.clone());
954 }
955 Ok(())
956 }
957 add(
958 "profile",
959 &mut latest.profiles,
960 &additions.profiles,
961 HarnessProfile::same_installation,
962 )?;
963 add(
964 "bundle",
965 &mut latest.bundles,
966 &additions.bundles,
967 PartialEq::eq,
968 )?;
969 add(
970 "target",
971 &mut latest.targets,
972 &additions.targets,
973 PartialEq::eq,
974 )?;
975 latest.validate()
976}
977
978fn write_discovered_homes(output: &mut impl Write, homes: &[DiscoveredHome]) -> Result<()> {
979 writeln!(output, "Harness homes:")?;
980 if homes.is_empty() {
981 writeln!(
982 output,
983 " No existing Codex, Claude Code, Kimi Code, or Grok Build homes found."
984 )?;
985 }
986 for home in homes {
987 let authentication = if home.authenticated {
988 "authenticated"
989 } else {
990 "not authenticated"
991 };
992 writeln!(
993 output,
994 " {}: {} ({authentication}){}",
995 home.kind.display_name(),
996 home.path.display(),
997 match home.kind.unsandboxed_guardian_warning() {
998 Some(warning) => format!(" — {warning}"),
999 None => String::new(),
1000 }
1001 )?;
1002 }
1003 Ok(())
1004}
1005
1006fn write_repository(output: &mut impl Write, repository: Option<&GithubRepository>) -> Result<()> {
1007 match repository {
1008 Some(repository) => writeln!(
1009 output,
1010 "GitHub origin: {} (a one-repository bundle will be created)",
1011 repository.source()
1012 )?,
1013 None => writeln!(
1014 output,
1015 "GitHub origin: none detected in the current directory."
1016 )?,
1017 }
1018 Ok(())
1019}
1020
1021fn write_runtimes(output: &mut impl Write, runtimes: &[RuntimeProbe]) -> Result<()> {
1022 writeln!(output, "Local runtimes:")?;
1023 for runtime in runtimes {
1024 let state = if runtime.usable {
1025 "usable"
1026 } else {
1027 "unavailable"
1028 };
1029 if runtime.detail.is_empty() {
1030 writeln!(output, " {}: {state}", runtime.kind.label())?;
1031 } else {
1032 writeln!(
1033 output,
1034 " {}: {state} ({})",
1035 runtime.kind.label(),
1036 runtime.detail
1037 )?;
1038 }
1039 if let Some(remediation) = &runtime.remediation {
1040 writeln!(output, " remediation: {remediation}")?;
1041 }
1042 }
1043 Ok(())
1044}
1045
1046fn prompt_aws_target(
1049 input: &mut impl SetupPrompter,
1050 output: &mut impl Write,
1051 account: Option<&AwsAccount>,
1052) -> Result<Option<AwsTargetInput>> {
1053 let Some(account) = account else {
1054 writeln!(
1055 output,
1056 "AWS: no working `aws` CLI credentials found; skipping the AWS target."
1057 )?;
1058 return Ok(None);
1059 };
1060 writeln!(
1061 output,
1062 "AWS: credentials are valid for account {} ({}).",
1063 account.account, account.arn
1064 )?;
1065 let answer = prompt(input, output, "Add an AWS EC2 target? [y/N]: ")?;
1066 if !matches!(answer.to_ascii_lowercase().as_str(), "y" | "yes") {
1067 return Ok(None);
1068 }
1069
1070 let launch_template = prompt(input, output, "Launch template name: ")?;
1071 if launch_template.is_empty() {
1072 writeln!(
1073 output,
1074 "A launch template name is required; skipping the AWS target."
1075 )?;
1076 return Ok(None);
1077 }
1078
1079 let region_label = match &account.region {
1080 Some(region) => format!("Region [{region}]: "),
1081 None => "Region: ".to_owned(),
1082 };
1083 let region = prompt(input, output, ®ion_label)?;
1084 let region = if region.is_empty() {
1085 match &account.region {
1086 Some(region) => region.clone(),
1087 None => {
1088 writeln!(output, "A region is required; skipping the AWS target.")?;
1089 return Ok(None);
1090 }
1091 }
1092 } else {
1093 region
1094 };
1095
1096 let ssh_user = prompt(
1097 input,
1098 output,
1099 &format!("SSH user [{DEFAULT_AWS_SSH_USER}]: "),
1100 )?;
1101 let ssh_user = if ssh_user.is_empty() {
1102 DEFAULT_AWS_SSH_USER.to_owned()
1103 } else {
1104 ssh_user
1105 };
1106 let identity_file = prompt(input, output, "SSH identity file (optional): ")?;
1107
1108 Ok(Some(AwsTargetInput {
1109 launch_template,
1110 region,
1111 ssh_user,
1112 identity_file: (!identity_file.is_empty()).then(|| PathBuf::from(identity_file)),
1113 }))
1114}
1115
1116fn prompt_ssh_target(
1121 input: &mut impl SetupPrompter,
1122 output: &mut impl Write,
1123 aliases: &[String],
1124 configured: &BTreeMap<String, TargetTemplate>,
1125) -> Result<Option<SshTargetInput>> {
1126 if aliases.is_empty() {
1127 writeln!(
1128 output,
1129 "SSH: no host aliases found in ~/.ssh/config; skipping the SSH target."
1130 )?;
1131 return Ok(None);
1132 }
1133 writeln!(output, "SSH: hosts found in ~/.ssh/config:")?;
1134 for (index, alias) in aliases.iter().enumerate() {
1135 writeln!(output, " {}) {alias}", index + 1)?;
1136 }
1137 let answer = prompt(input, output, "Add an SSH target? [y/N]: ")?;
1138 if !matches!(answer.to_ascii_lowercase().as_str(), "y" | "yes") {
1139 return Ok(None);
1140 }
1141
1142 let choice = prompt(
1143 input,
1144 output,
1145 &format!("Host number 1-{} or a host name: ", aliases.len()),
1146 )?;
1147 let host = match choice.parse::<usize>() {
1148 Ok(index) if (1..=aliases.len()).contains(&index) => aliases[index - 1].clone(),
1149 _ if !choice.is_empty() => choice,
1150 _ => {
1151 writeln!(output, "A host is required; skipping the SSH target.")?;
1152 return Ok(None);
1153 }
1154 };
1155
1156 let kind = loop {
1157 let runtime = prompt(
1158 input,
1159 output,
1160 "Container runtime on that host, podman, docker, or bare [podman]: ",
1161 )?;
1162 let runtime = runtime.to_ascii_lowercase();
1163 if matches!(runtime.as_str(), "n" | "no" | "bare") {
1164 let permissions = loop {
1165 let mode = prompt(
1166 input,
1167 output,
1168 "Raw-host permissions, guardian or yolo [guardian]: ",
1169 )?;
1170 match mode.to_ascii_lowercase().as_str() {
1171 "" | "guardian" => break PermissionMode::Guardian,
1172 "yolo" => break PermissionMode::Yolo,
1173 _ => writeln!(output, "Permissions must be `guardian` or `yolo`.")?,
1174 }
1175 };
1176 break SshTargetKind::Bare { permissions };
1177 }
1178 let kind = if matches!(runtime.as_str(), "" | "y" | "yes" | "podman") {
1179 SshTargetKind::Podman {
1180 image: String::new(),
1181 }
1182 } else if runtime == "docker" {
1183 SshTargetKind::Docker {
1184 image: String::new(),
1185 }
1186 } else {
1187 writeln!(
1188 output,
1189 "Runtime must be `podman`, `docker`, or `bare`; please choose again."
1190 )?;
1191 continue;
1192 };
1193 let image = prompt(
1194 input,
1195 output,
1196 &format!("Container image [{DEFAULT_IMAGE}]: "),
1197 )?;
1198 let image = if image.is_empty() {
1199 DEFAULT_IMAGE.to_owned()
1200 } else {
1201 image
1202 };
1203 break match kind {
1204 SshTargetKind::Podman { .. } => SshTargetKind::Podman { image },
1205 SshTargetKind::Docker { .. } => SshTargetKind::Docker { image },
1206 SshTargetKind::Bare { .. } => unreachable!("bare runtime returned above"),
1207 };
1208 };
1209
1210 let Some(name) = prompt_ssh_target_name(input, output, &host, configured)? else {
1211 return Ok(None);
1212 };
1213
1214 Ok(Some(SshTargetInput { name, host, kind }))
1215}
1216
1217fn prompt_ssh_target_name(
1224 input: &mut impl SetupPrompter,
1225 output: &mut impl Write,
1226 host: &str,
1227 configured: &BTreeMap<String, TargetTemplate>,
1228) -> Result<Option<String>> {
1229 loop {
1230 let Some(answer) = prompt_line(input, output, &format!("Target name [{host}]: "))? else {
1231 writeln!(output, "Input ended; skipping the SSH target.")?;
1232 return Ok(None);
1233 };
1234 let name = if answer.is_empty() {
1235 host.to_owned()
1236 } else {
1237 answer
1238 };
1239 if let Err(error) = validate_id("target", &name) {
1240 writeln!(output, "{error}")?;
1241 continue;
1242 }
1243 if configured.contains_key(&name) {
1244 writeln!(
1245 output,
1246 "Target {name} is already configured; choose another name."
1247 )?;
1248 continue;
1249 }
1250 return Ok(Some(name));
1251 }
1252}
1253
1254fn smoke_failure_check(runtime: RuntimeKind, image: &str, error: &anyhow::Error) -> DoctorCheck {
1257 let scope = match runtime {
1258 RuntimeKind::Docker => "Disposable run/exec/remove and OverlayFS attachment smoke test",
1259 RuntimeKind::Podman | RuntimeKind::AppleContainer => {
1260 "Disposable run/exec/remove smoke test"
1261 }
1262 };
1263 DoctorCheck::fixable(
1264 format!("runtime.{}.smoke", runtime.id()),
1265 format!("{} smoke test", runtime.label()),
1266 format!("{scope} failed for image {image}: {error:#}"),
1267 format!(
1268 "Fix the configured image or the {} runtime, then run `mj doctor --smoke` again.",
1269 runtime.label()
1270 ),
1271 )
1272}
1273
1274fn write_doctor_report(
1280 output: &mut impl Write,
1281 config_path: &Path,
1282 executor: &impl CommandExecutor,
1283 extra: Vec<DoctorCheck>,
1284) -> Result<()> {
1285 writeln!(output)?;
1286 writeln!(output, "Running `mj doctor` checks on the new config...")?;
1287 let mut checks = run_with_config_path(
1288 config_path,
1289 executor,
1290 current_apple_platform(executor),
1291 DoctorOptions { smoke: false },
1292 );
1293 checks.extend(extra);
1294 render_human(&checks, output)?;
1295 if all_ready(&checks) {
1296 writeln!(output, "Every check is ready.")?;
1297 } else {
1298 writeln!(
1299 output,
1300 "Apply the remediations above, then rerun `mj doctor`."
1301 )?;
1302 }
1303 Ok(())
1304}
1305
1306fn prompt(input: &mut impl SetupPrompter, output: &mut impl Write, label: &str) -> Result<String> {
1307 Ok(prompt_line(input, output, label)?.unwrap_or_default())
1308}
1309
1310fn prompt_line(
1316 input: &mut impl SetupPrompter,
1317 output: &mut impl Write,
1318 label: &str,
1319) -> Result<Option<String>> {
1320 input.read_prompt(output, label)
1321}
1322
1323trait SetupPrompter {
1324 fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>>;
1325}
1326
1327impl<R: BufRead> SetupPrompter for R {
1328 fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>> {
1329 write!(output, "{label}")?;
1330 output.flush()?;
1331 let mut answer = String::new();
1332 let read = self.read_line(&mut answer).context("read setup response")?;
1333 Ok((read > 0).then(|| answer.trim().to_owned()))
1334 }
1335}
1336
1337#[derive(Default)]
1338struct ReadlinePrompter(crate::readline::LineReader);
1339
1340impl SetupPrompter for ReadlinePrompter {
1341 fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>> {
1342 output.flush()?;
1343 self.0.read_line(label).context("read setup response")
1344 }
1345}
1346
1347fn write_summary(
1348 output: &mut impl Write,
1349 config_path: &Path,
1350 config: &Config,
1351 runtimes: &[(RuntimeKind, String)],
1352) -> Result<()> {
1353 writeln!(output, "Mjolnir will add to {}:", config_path.display())?;
1354 writeln!(output, " {} profile(s)", config.profiles.len())?;
1355 writeln!(output, " {} bundle(s)", config.bundles.len())?;
1356 if config
1357 .targets
1358 .values()
1359 .any(|target| matches!(target, TargetTemplate::LocalBare))
1360 {
1361 writeln!(
1362 output,
1363 " raw localhost target using configured harness homes directly"
1364 )?;
1365 }
1366 for (runtime, image) in runtimes {
1367 writeln!(output, " {} target using {image}", runtime.label())?;
1368 }
1369 for id in config.targets.keys() {
1370 writeln!(output, " target id: {id}")?;
1371 }
1372 if let Some(TargetTemplate::AwsEc2 {
1373 launch_template,
1374 region,
1375 ..
1376 }) = config.targets.get(AWS_TARGET_ID)
1377 {
1378 writeln!(
1379 output,
1380 " AWS EC2 target using launch template {launch_template} in {region}"
1381 )?;
1382 }
1383 for (id, target) in &config.targets {
1384 match target {
1385 TargetTemplate::SshBare { ssh, .. } => {
1386 writeln!(output, " SSH target {id} on {} (no container)", ssh.host)?;
1387 }
1388 TargetTemplate::SshPodman { ssh, container, .. } => {
1389 writeln!(
1390 output,
1391 " SSH target {id} on {} using Podman image {}",
1392 ssh.host, container.image
1393 )?;
1394 }
1395 TargetTemplate::SshDocker { ssh, container } => {
1396 writeln!(
1397 output,
1398 " SSH target {id} on {} using Docker image {}",
1399 ssh.host, container.image
1400 )?;
1401 }
1402 _ => {}
1403 }
1404 }
1405 if config_path.exists() {
1406 writeln!(
1407 output,
1408 " Existing profiles, bundles, targets, and preferences will be preserved."
1409 )?;
1410 }
1411 Ok(())
1412}
1413
1414fn smoke_target(runtime: RuntimeKind, image: &str) -> RuntimeTargetTemplate {
1415 let container = RuntimeContainerTemplate {
1416 image: image.to_owned(),
1417 pull_policy: Default::default(),
1418 extra_run_args: vec![],
1419 workspace_storage: Default::default(),
1420 };
1421 match runtime {
1422 RuntimeKind::Podman => RuntimeTargetTemplate::LocalPodman(container),
1423 RuntimeKind::Docker => RuntimeTargetTemplate::LocalDocker(container),
1424 RuntimeKind::AppleContainer => RuntimeTargetTemplate::AppleContainer(container),
1425 }
1426}
1427
1428fn run_smoke_test(
1429 output: &mut impl Write,
1430 target: &RuntimeTargetTemplate,
1431 executor: &impl CommandExecutor,
1432) -> Result<()> {
1433 let smoke_id = format!(
1434 "setup-{}-{:x}",
1435 std::process::id(),
1436 SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos()
1437 );
1438 let description = match target {
1439 RuntimeTargetTemplate::LocalDocker(_) => {
1440 "Smoke test: verifying a disposable container and writable OverlayFS attachment..."
1441 }
1442 _ => "Smoke test: verifying a disposable container...",
1443 };
1444 writeln!(output, "{description}")?;
1445 run_setup_smoke_test(target, &smoke_id, executor)
1446}
1447
1448#[cfg(test)]
1449mod tests {
1450 use std::cell::RefCell;
1451 use std::fs;
1452
1453 use super::*;
1454 use crate::targets::CommandOutput;
1455
1456 #[cfg(unix)]
1457 #[test]
1458 fn first_terminal_launch_writes_a_local_codex_config_once() {
1459 let directory = tempfile::tempdir().unwrap();
1460 let path = directory.path().join("config.toml");
1461 initialize_local_startup_config(&path).unwrap();
1462 let config = Config::load_from(&path).unwrap();
1463 assert_eq!(config.profiles["codex"].kind, HarnessKind::Codex);
1464 assert!(config.profiles["codex"].home.is_absolute());
1465 assert!(matches!(
1466 config.targets["localhost"],
1467 TargetTemplate::LocalBare
1468 ));
1469 assert!(config.bundles.is_empty());
1470 let written = fs::read(&path).unwrap();
1471 initialize_local_startup_config(&path).unwrap();
1472 assert_eq!(fs::read(&path).unwrap(), written);
1473 }
1474
1475 #[cfg(unix)]
1476 #[test]
1477 fn local_startup_preserves_existing_settings_and_ignores_disabled_startup() {
1478 let directory = tempfile::tempdir().unwrap();
1479 let path = directory.path().join("config.toml");
1480 let mut config = Config::default();
1481 config.phone.enabled = false;
1482 config.save_to(&path).unwrap();
1483 initialize_local_startup_config(&path).unwrap();
1484 assert!(!Config::load_from(&path).unwrap().phone.enabled);
1485
1486 let configured =
1488 "version = 2\n# keep this comment\n[targets.custom]\nkind = 'local-bare'\n";
1489 fs::write(&path, configured).unwrap();
1490 initialize_local_startup_config(&path).unwrap();
1491 assert_eq!(fs::read_to_string(&path).unwrap(), configured);
1492
1493 let disabled = "version = 2\n[startup]\nenabled = false\n";
1494 fs::write(&path, disabled).unwrap();
1495 initialize_local_startup_config(&path).unwrap();
1496 let bootstrapped = Config::load_from(&path).unwrap();
1497 assert!(
1498 serde_json::to_value(&bootstrapped)
1499 .unwrap()
1500 .get("startup")
1501 .is_none()
1502 );
1503 assert_eq!(bootstrapped.profiles["codex"].kind, HarnessKind::Codex);
1504 assert!(matches!(
1505 bootstrapped.targets["localhost"],
1506 TargetTemplate::LocalBare
1507 ));
1508
1509 let newer = "version = 999\nfuture_field = true\n";
1510 fs::write(&path, newer).unwrap();
1511 initialize_local_startup_config(&path).unwrap();
1512 assert_eq!(fs::read_to_string(&path).unwrap(), newer);
1513 }
1514
1515 struct FakeExecutor {
1516 commands: RefCell<Vec<CommandSpec>>,
1517 statuses: Vec<i32>,
1518 }
1519
1520 impl FakeExecutor {
1521 fn succeeds() -> Self {
1522 Self {
1523 commands: RefCell::new(vec![]),
1524 statuses: vec![0, 0, 0],
1525 }
1526 }
1527 }
1528
1529 impl CommandExecutor for FakeExecutor {
1530 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1531 let index = self.commands.borrow().len();
1532 self.commands.borrow_mut().push(command.clone());
1533 Ok(CommandOutput {
1534 status: self.statuses.get(index).copied().unwrap_or(0),
1535 stdout: b"available".to_vec(),
1536 stderr: b"failed".to_vec(),
1537 })
1538 }
1539 }
1540
1541 struct RuntimeProbeExecutor {
1542 commands: RefCell<Vec<CommandSpec>>,
1543 outputs: RefCell<Vec<CommandOutput>>,
1544 }
1545
1546 impl RuntimeProbeExecutor {
1547 fn new(outputs: impl IntoIterator<Item = CommandOutput>) -> Self {
1548 Self {
1549 commands: RefCell::new(vec![]),
1550 outputs: RefCell::new(outputs.into_iter().collect()),
1551 }
1552 }
1553 }
1554
1555 impl CommandExecutor for RuntimeProbeExecutor {
1556 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1557 self.commands.borrow_mut().push(command.clone());
1558 if self.outputs.borrow().is_empty() {
1559 anyhow::bail!("no canned output for {}", command.program);
1560 }
1561 Ok(self.outputs.borrow_mut().remove(0))
1562 }
1563 }
1564
1565 fn ok(stdout: &[u8]) -> CommandOutput {
1566 CommandOutput {
1567 status: 0,
1568 stdout: stdout.to_vec(),
1569 stderr: vec![],
1570 }
1571 }
1572
1573 fn failed(stderr: &[u8]) -> CommandOutput {
1574 CommandOutput {
1575 status: 1,
1576 stdout: vec![],
1577 stderr: stderr.to_vec(),
1578 }
1579 }
1580
1581 const CALLER_IDENTITY: &[u8] =
1582 br#"{"UserId":"AIDA","Account":"123456789012","Arn":"arn:aws:iam::123456789012:user/dev"}"#;
1583
1584 fn discovery_without_runtimes() -> SetupDiscovery {
1585 SetupDiscovery {
1586 homes: vec![],
1587 repository: None,
1588 runtimes: vec![],
1589 aws: None,
1590 ssh_hosts: vec![],
1591 }
1592 }
1593
1594 #[test]
1595 fn newly_installed_harness_is_discovered_before_its_first_login() {
1596 struct InstalledMuse;
1597 impl CommandExecutor for InstalledMuse {
1598 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1599 assert_ne!(command.program, "zcode", "Setup must not open ZCode's GUI");
1600 assert_eq!(command.args, ["--version"]);
1601 Ok(CommandOutput {
1602 status: if command.program == "muse" { 0 } else { 127 },
1603 stdout: Vec::new(),
1604 stderr: Vec::new(),
1605 })
1606 }
1607 }
1608 let directory = tempfile::tempdir().unwrap();
1609 let path = directory.path().join("custom-muse-home");
1610 let overrides = BTreeMap::from([(HarnessKind::Muse, path.clone())]);
1611 let mut homes = Vec::new();
1612 for _ in 0..2 {
1613 discover_installed_harnesses(
1614 Some(directory.path()),
1615 &overrides,
1616 &mut homes,
1617 &InstalledMuse,
1618 );
1619 assert_eq!(
1620 homes,
1621 vec![DiscoveredHome {
1622 kind: HarnessKind::Muse,
1623 path: path.clone(),
1624 authenticated: false
1625 }]
1626 );
1627 assert!(!path.exists(), "discovery must not create a profile");
1628 }
1629 }
1630
1631 #[test]
1632 fn discovers_default_and_overridden_homes_with_authentication_markers() {
1633 let directory = tempfile::tempdir().unwrap();
1634 let home = directory.path().join("home");
1635 let codex = home.join(".codex");
1636 let kimi = home.join(".kimi-code");
1637 let grok = home.join(".grok");
1638 let deepseek = home.join(".dsh");
1639 let claude = directory.path().join("claude-override");
1640 fs::create_dir_all(&codex).unwrap();
1641 fs::create_dir_all(kimi.join("credentials")).unwrap();
1642 fs::create_dir_all(&grok).unwrap();
1643 fs::create_dir_all(&deepseek).unwrap();
1644 fs::create_dir_all(&claude).unwrap();
1645 fs::write(codex.join("auth.json"), "{}").unwrap();
1646 fs::write(kimi.join("credentials/kimi-code.json"), "{}").unwrap();
1647 fs::write(grok.join("auth.json"), "{}").unwrap();
1648 fs::write(deepseek.join(".credentials.yaml"), "version: 1\n").unwrap();
1649 fs::write(claude.join(".credentials.json"), "{}").unwrap();
1650
1651 let executor = FakeExecutor::succeeds();
1652 let homes = discover_harness_homes_with_executor(
1653 Some(&home),
1654 [(HarnessKind::Claude, claude.clone())],
1655 &executor,
1656 );
1657
1658 assert_eq!(homes.len(), 5);
1659 assert!(homes.iter().all(|home| home.authenticated));
1660 assert!(homes.iter().any(|home| home.path == codex));
1661 assert!(homes.iter().any(|home| home.path == claude));
1662 assert!(homes.iter().any(|home| home.path == kimi));
1663 assert!(
1664 homes
1665 .iter()
1666 .any(|home| { home.path == deepseek && home.kind == HarnessKind::Deepseek })
1667 );
1668 assert!(
1669 homes
1670 .iter()
1671 .any(|home| home.path == grok && home.kind == HarnessKind::Grok)
1672 );
1673 }
1674
1675 #[test]
1676 fn every_harness_has_a_discoverable_default_home() {
1677 let directory = tempfile::tempdir().unwrap();
1678 let home = directory.path().to_path_buf();
1679 for kind in HarnessKind::ALL {
1680 fs::create_dir_all(home.join(kind.default_home_leaf())).unwrap();
1681 }
1682
1683 let executor = FakeExecutor::succeeds();
1684 let homes = discover_harness_homes_with_executor(Some(&home), [], &executor);
1685
1686 assert_eq!(homes.len(), HarnessKind::ALL.len());
1687 for kind in HarnessKind::ALL {
1688 assert!(
1689 homes
1690 .iter()
1691 .any(|home| home.kind == kind && !home.authenticated),
1692 "{kind:?} default home"
1693 );
1694 }
1695 }
1696
1697 #[cfg(target_os = "macos")]
1698 #[test]
1699 fn claude_keychain_marks_the_default_home_authenticated_without_a_marker() {
1700 let directory = tempfile::tempdir().unwrap();
1701 let home = directory.path().join("home");
1702 let claude = home.join(".claude");
1703 fs::create_dir_all(&claude).unwrap();
1704 let executor = RuntimeProbeExecutor::new([ok(
1705 br#"{"claudeAiOauth":{"accessToken":"access","refreshToken":"refresh"}}"#,
1706 )]);
1707
1708 let homes = discover_harness_homes_with_executor(Some(&home), [], &executor);
1709
1710 assert_eq!(
1711 homes,
1712 vec![DiscoveredHome {
1713 kind: HarnessKind::Claude,
1714 path: claude.clone(),
1715 authenticated: true,
1716 }]
1717 );
1718 let commands = executor.commands.borrow();
1719 assert_eq!(commands.len(), 1);
1720 assert_eq!(commands[0].program, "security");
1721 assert_eq!(
1722 commands[0].args,
1723 [
1724 "find-generic-password",
1725 "-s",
1726 "Claude Code-credentials",
1727 "-w"
1728 ]
1729 );
1730 assert!(commands[0].env.is_empty());
1731 }
1732
1733 #[test]
1734 fn claude_status_checks_a_custom_home_without_a_marker() {
1735 let directory = tempfile::tempdir().unwrap();
1736 let claude = directory.path().join("claude-custom");
1737 fs::create_dir_all(&claude).unwrap();
1738 let executor =
1739 RuntimeProbeExecutor::new([ok(br#"{"loggedIn":true,"authMethod":"claude.ai"}"#)]);
1740
1741 let homes = discover_harness_homes_with_executor(
1742 None,
1743 [(HarnessKind::Claude, claude.clone())],
1744 &executor,
1745 );
1746
1747 assert_eq!(
1748 homes,
1749 vec![DiscoveredHome {
1750 kind: HarnessKind::Claude,
1751 path: claude.clone(),
1752 authenticated: true,
1753 }]
1754 );
1755 let commands = executor.commands.borrow();
1756 assert_eq!(commands.len(), 1);
1757 assert_eq!(commands[0].program, "claude");
1758 assert_eq!(commands[0].args, ["auth", "status", "--json"]);
1759 assert_eq!(
1760 commands[0].env.get("CLAUDE_CONFIG_DIR"),
1761 Some(&claude.to_string_lossy().into_owned())
1762 );
1763 }
1764
1765 #[test]
1766 fn claude_credential_evidence_requires_a_nonempty_login_secret() {
1767 assert!(claude_credentials_contain_login(
1768 br#"{"claudeAiOauth":{"refreshToken":"refresh"}}"#
1769 ));
1770 assert!(!claude_credentials_contain_login(
1771 br#"{"claudeAiOauth":{"refreshToken":" "}}"#
1772 ));
1773 assert!(!claude_credentials_contain_login(b"not json"));
1774 }
1775
1776 #[test]
1777 fn github_origin_parser_accepts_standard_https_and_ssh_forms() {
1778 for origin in [
1779 "https://github.com/BrokkAi/hel.git",
1780 "git@github.com:BrokkAi/hel.git",
1781 "ssh://git@github.com/BrokkAi/hel.git",
1782 ] {
1783 assert_eq!(
1784 github_repository_from_origin(origin),
1785 Some(GithubRepository {
1786 owner: "BrokkAi".into(),
1787 repository: "hel".into(),
1788 })
1789 );
1790 }
1791 assert_eq!(
1792 github_repository_from_origin("https://example.com/hel"),
1793 None
1794 );
1795 }
1796
1797 #[test]
1798 fn config_contains_discovered_profiles_current_repository_and_selected_target() {
1799 let homes = vec![
1800 DiscoveredHome {
1801 kind: HarnessKind::Codex,
1802 path: PathBuf::from("/profiles/codex"),
1803 authenticated: true,
1804 },
1805 DiscoveredHome {
1806 kind: HarnessKind::Codex,
1807 path: PathBuf::from("/profiles/codex-two"),
1808 authenticated: false,
1809 },
1810 ];
1811 let repository = GithubRepository {
1812 owner: "BrokkAi".into(),
1813 repository: "hel".into(),
1814 };
1815
1816 let config = build_config(
1817 &homes,
1818 Some(&repository),
1819 RuntimeKind::Podman,
1820 "ubuntu:24.04",
1821 );
1822
1823 config.validate().unwrap();
1824 assert!(config.profiles.contains_key("codex"));
1825 assert!(config.profiles.contains_key("codex-2"));
1826 assert_eq!(
1827 config.bundles["current-repository"].repositories[0]
1828 .github
1829 .as_deref(),
1830 Some("BrokkAi/hel")
1831 );
1832 assert!(matches!(
1833 config.targets["podman"],
1834 TargetTemplate::LocalPodman { .. }
1835 ));
1836 assert!(matches!(
1837 config.targets["localhost"],
1838 TargetTemplate::LocalBare
1839 ));
1840
1841 let docker = build_config(
1842 &homes,
1843 Some(&repository),
1844 RuntimeKind::Docker,
1845 "ubuntu:24.04",
1846 );
1847 assert!(matches!(
1848 docker.targets["docker"],
1849 TargetTemplate::LocalDocker { .. }
1850 ));
1851 }
1852
1853 #[test]
1854 fn runtime_probe_requires_podman_rootless_preflight_and_checks_apple_on_macos() {
1855 let executor = RuntimeProbeExecutor::new([
1856 ok(b"podman version 5.4.2\n"),
1857 ok(b"true\n"),
1858 ok(b"0 1000 1\n1 100000 65536\n"),
1859 ok(b"29.0.1 linux\n"),
1860 ok(b"container version 1\n"),
1861 ok(b"running\n"),
1862 ]);
1863 let runtimes = probe_local_runtimes(&executor, true);
1864
1865 assert_eq!(runtimes.len(), 3);
1866 assert_eq!(executor.commands.borrow()[0].program, "podman");
1867 assert_eq!(executor.commands.borrow()[0].args, ["--version"]);
1868 assert_eq!(
1869 executor.commands.borrow()[1].args,
1870 ["info", "--format", "{{.Host.Security.Rootless}}"]
1871 );
1872 assert_eq!(
1873 executor.commands.borrow()[2].args,
1874 ["unshare", "cat", "/proc/self/uid_map"]
1875 );
1876 assert_eq!(executor.commands.borrow()[3].program, "docker");
1877 assert_eq!(executor.commands.borrow()[4].program, "container");
1878 assert!(runtimes.iter().all(|runtime| runtime.usable));
1879 }
1880
1881 #[test]
1882 fn unusable_podman_carries_the_doctor_remediation_into_the_runtime_list() {
1883 let executor = RuntimeProbeExecutor::new([
1884 ok(b"podman version 3.4.7\n"),
1885 failed(b"docker is unavailable"),
1886 ]);
1887
1888 let runtimes = probe_local_runtimes(&executor, false);
1889
1890 assert_eq!(runtimes.len(), 2);
1891 assert!(!runtimes[0].usable);
1892 let remediation = runtimes[0].remediation.as_deref().unwrap();
1893 assert!(remediation.contains("Upgrade Podman"), "{remediation}");
1894
1895 let mut output = Vec::new();
1896 write_runtimes(&mut output, &runtimes).unwrap();
1897 let output = String::from_utf8(output).unwrap();
1898 assert!(output.contains("Podman: unavailable"), "{output}");
1899 assert!(output.contains("Docker: unavailable"), "{output}");
1900 assert!(output.contains("remediation: Upgrade Podman"), "{output}");
1901 }
1902
1903 #[test]
1904 fn aws_is_detected_only_when_the_caller_identity_call_succeeds() {
1905 let missing = RuntimeProbeExecutor::new([]);
1906 assert_eq!(detect_aws(&missing), None);
1907
1908 let denied = RuntimeProbeExecutor::new([failed(b"ExpiredToken")]);
1909 assert_eq!(detect_aws(&denied), None);
1910
1911 let working = RuntimeProbeExecutor::new([ok(CALLER_IDENTITY), ok(b"us-east-1\n")]);
1912 assert_eq!(
1913 detect_aws(&working),
1914 Some(AwsAccount {
1915 account: "123456789012".into(),
1916 arn: "arn:aws:iam::123456789012:user/dev".into(),
1917 region: Some("us-east-1".into()),
1918 })
1919 );
1920 assert_eq!(working.commands.borrow()[0].args[0], "sts");
1921 assert_eq!(
1922 working.commands.borrow()[1].args,
1923 ["configure", "get", "region"]
1924 );
1925 }
1926
1927 #[test]
1928 fn aws_detection_without_a_configured_region_leaves_the_region_unset() {
1929 let executor = RuntimeProbeExecutor::new([ok(CALLER_IDENTITY), failed(b"")]);
1930
1931 assert_eq!(detect_aws(&executor).unwrap().region, None);
1932 }
1933
1934 #[test]
1935 fn the_aws_step_asks_nothing_when_no_aws_credentials_were_detected() {
1936 let mut input = b"".as_slice();
1937 let mut output = Vec::new();
1938
1939 let aws = prompt_aws_target(&mut input, &mut output, None).unwrap();
1940
1941 assert_eq!(aws, None);
1942 let output = String::from_utf8(output).unwrap();
1943 assert!(output.contains("skipping the AWS target"), "{output}");
1944 assert!(!output.contains("[y/N]"), "{output}");
1945 }
1946
1947 #[test]
1948 fn the_aws_step_defaults_region_and_ssh_user_when_the_answers_are_blank() {
1949 let account = AwsAccount {
1950 account: "123456789012".into(),
1951 arn: "arn:aws:iam::123456789012:user/dev".into(),
1952 region: Some("us-east-1".into()),
1953 };
1954 let mut input = b"y\nhel-runson\n\n\n\n".as_slice();
1955 let mut output = Vec::new();
1956
1957 let aws = prompt_aws_target(&mut input, &mut output, Some(&account))
1958 .unwrap()
1959 .unwrap();
1960
1961 assert_eq!(
1962 aws,
1963 AwsTargetInput {
1964 launch_template: "hel-runson".into(),
1965 region: "us-east-1".into(),
1966 ssh_user: DEFAULT_AWS_SSH_USER.into(),
1967 identity_file: None,
1968 }
1969 );
1970 let config = build_config_with_runtime(&[], None, None, Some(&aws), None);
1971 let TargetTemplate::AwsEc2 {
1972 region,
1973 launch_template,
1974 ssh_user,
1975 ..
1976 } = &config.targets[AWS_TARGET_ID]
1977 else {
1978 panic!("setup must write an aws-ec2 target");
1979 };
1980 assert_eq!(region, "us-east-1");
1981 assert_eq!(launch_template, "hel-runson");
1982 assert_eq!(ssh_user, DEFAULT_AWS_SSH_USER);
1983 config.validate().unwrap();
1984 }
1985
1986 const SSH_CONFIG_FIXTURE: &str = r#"
1987# Personal hosts
1988Host *
1989 ServerAliveInterval 60
1990
1991Host builder build.example.com
1992 HostName build.example.com
1993 User dev
1994
1995Host bastion
1996 HostName 10.0.0.1
1997 IdentityFile ~/.ssh/id_ed25519
1998
1999Host prod-*
2000 User deploy
2001
2002Host !staging *.internal
2003 User deploy
2004
2005Host builder
2006 Compression yes
2007"#;
2008
2009 #[test]
2010 fn ssh_config_parsing_keeps_concrete_aliases_and_drops_pattern_blocks() {
2011 let aliases = ssh_config_aliases(SSH_CONFIG_FIXTURE);
2012
2013 assert_eq!(
2014 aliases,
2015 vec!["builder", "build.example.com", "bastion"],
2016 "wildcard, negated, and duplicate entries must not appear"
2017 );
2018 }
2019
2020 #[test]
2021 fn ssh_config_parsing_returns_nothing_for_a_config_of_only_wildcards() {
2022 assert!(
2023 ssh_config_aliases(
2024 "Host *
2025 User dev
2026"
2027 )
2028 .is_empty()
2029 );
2030 assert!(ssh_config_aliases("").is_empty());
2031 }
2032
2033 #[test]
2034 fn the_ssh_step_asks_nothing_when_the_ssh_config_has_no_aliases() {
2035 let mut input = b"".as_slice();
2036 let mut output = Vec::new();
2037
2038 assert_eq!(
2039 prompt_ssh_target(&mut input, &mut output, &[], &BTreeMap::new()).unwrap(),
2040 None
2041 );
2042 let output = String::from_utf8(output).unwrap();
2043 assert!(output.contains("skipping the SSH target"), "{output}");
2044 assert!(!output.contains("[y/N]"), "{output}");
2045 }
2046
2047 #[test]
2048 fn declining_the_ssh_step_writes_no_ssh_target() {
2049 let aliases = vec!["builder".to_owned()];
2050 let mut input = b"\n".as_slice();
2051 let mut output = Vec::new();
2052
2053 assert_eq!(
2054 prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new()).unwrap(),
2055 None
2056 );
2057 let config = build_config_with_runtime(&[], None, None, None, None);
2058 assert!(!config.targets.values().any(|target| matches!(
2059 target,
2060 TargetTemplate::SshBare { .. }
2061 | TargetTemplate::SshPodman { .. }
2062 | TargetTemplate::SshDocker { .. }
2063 )));
2064 }
2065
2066 #[test]
2067 fn accepting_the_ssh_step_can_write_a_docker_target() {
2068 let aliases = vec!["builder".to_owned()];
2069 let mut input = b"y\n1\ndocker\n\n\n".as_slice();
2071 let mut output = Vec::new();
2072
2073 let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new())
2074 .unwrap()
2075 .unwrap();
2076 assert_eq!(
2077 ssh.kind,
2078 SshTargetKind::Docker {
2079 image: DEFAULT_IMAGE.into()
2080 }
2081 );
2082 let config = build_config_with_runtime(&[], None, None, None, Some(&ssh));
2083 let TargetTemplate::SshDocker { ssh, container } = &config.targets["builder"] else {
2084 panic!("setup must write an ssh-docker target");
2085 };
2086 assert_eq!(ssh.host, "builder");
2087 assert_eq!(container.image, DEFAULT_IMAGE);
2088 config.validate().unwrap();
2089 }
2090
2091 #[test]
2092 fn the_ssh_step_rejects_an_unknown_runtime_before_asking_for_an_image() {
2093 let aliases = vec!["builder".to_owned()];
2094 let mut input = b"y\n1\ncontainerd\ndocker\n\n\n".as_slice();
2095 let mut output = Vec::new();
2096
2097 let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new())
2098 .unwrap()
2099 .unwrap();
2100
2101 assert!(matches!(ssh.kind, SshTargetKind::Docker { .. }));
2102 let output = String::from_utf8(output).unwrap();
2103 assert!(output.contains("Runtime must be"), "{output}");
2104 assert_eq!(output.matches("Container image").count(), 1);
2105 }
2106
2107 #[test]
2108 fn accepting_the_ssh_step_writes_an_ssh_podman_target_with_the_default_image() {
2109 let aliases = vec!["builder".to_owned(), "bastion".to_owned()];
2110 let mut input = b"y\n1\n\n\n\n".as_slice();
2112 let mut output = Vec::new();
2113
2114 let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new())
2115 .unwrap()
2116 .unwrap();
2117
2118 assert_eq!(
2119 ssh,
2120 SshTargetInput {
2121 name: "builder".into(),
2122 host: "builder".into(),
2123 kind: SshTargetKind::Podman {
2124 image: DEFAULT_IMAGE.into()
2125 },
2126 }
2127 );
2128 let config = build_config_with_runtime(&[], None, None, None, Some(&ssh));
2129 let TargetTemplate::SshPodman { ssh, container, .. } = &config.targets["builder"] else {
2130 panic!("setup must write an ssh-podman target");
2131 };
2132 assert_eq!(ssh.host, "builder");
2133 assert_eq!(ssh.user, None);
2134 assert_eq!(ssh.identity_file, None);
2135 assert_eq!(container.image, DEFAULT_IMAGE);
2136 config.validate().unwrap();
2137 }
2138
2139 #[test]
2140 fn accepting_the_ssh_step_writes_an_ssh_bare_target_under_a_chosen_name() {
2141 let aliases = vec!["builder".to_owned()];
2142 let mut input = b"y\nother.example.com\nn\n\nremote\n".as_slice();
2144 let mut output = Vec::new();
2145
2146 let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new())
2147 .unwrap()
2148 .unwrap();
2149
2150 assert_eq!(
2151 ssh,
2152 SshTargetInput {
2153 name: "remote".into(),
2154 host: "other.example.com".into(),
2155 kind: SshTargetKind::Bare {
2156 permissions: PermissionMode::Guardian,
2157 },
2158 }
2159 );
2160 let config = build_config_with_runtime(&[], None, None, None, Some(&ssh));
2161 let TargetTemplate::SshBare {
2162 ssh, permissions, ..
2163 } = &config.targets["remote"]
2164 else {
2165 panic!("setup must write an ssh-bare target");
2166 };
2167 assert_eq!(ssh.host, "other.example.com");
2168 assert_eq!(*permissions, PermissionMode::Guardian);
2169 config.validate().unwrap();
2170 }
2171
2172 #[test]
2173 fn the_ssh_step_reasks_until_the_name_is_a_free_and_valid_target_id() {
2174 let aliases = vec!["builder".to_owned()];
2175 let configured = build_config_with_runtime(
2176 &[],
2177 None,
2178 Some((RuntimeKind::Podman, DEFAULT_IMAGE)),
2179 None,
2180 None,
2181 )
2182 .targets;
2183 let mut input = b"y\n1\nn\n\npodman\nbuild host\nbuilder\n".as_slice();
2186 let mut output = Vec::new();
2187
2188 let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &configured)
2189 .unwrap()
2190 .unwrap();
2191
2192 assert_eq!(ssh.name, "builder");
2193 let output = String::from_utf8(output).unwrap();
2194 assert!(
2195 output.contains("Target podman is already configured"),
2196 "{output}"
2197 );
2198 assert!(output.contains("invalid target id"), "{output}");
2199 }
2200
2201 #[test]
2202 fn the_ssh_step_stops_asking_for_a_name_once_the_input_ends() {
2203 let aliases = vec!["podman".to_owned()];
2204 let configured = build_config_with_runtime(
2205 &[],
2206 None,
2207 Some((RuntimeKind::Podman, DEFAULT_IMAGE)),
2208 None,
2209 None,
2210 )
2211 .targets;
2212 let mut input = b"y\n1\nn\n\n".as_slice();
2215 let mut output = Vec::new();
2216
2217 assert_eq!(
2218 prompt_ssh_target(&mut input, &mut output, &aliases, &configured).unwrap(),
2219 None
2220 );
2221 let output = String::from_utf8(output).unwrap();
2222 assert!(output.contains("Input ended; skipping"), "{output}");
2223 }
2224
2225 #[test]
2226 fn an_ssh_target_never_replaces_a_target_configured_earlier() {
2227 let ssh = SshTargetInput {
2228 name: "podman".into(),
2229 host: "builder".into(),
2230 kind: SshTargetKind::Bare {
2231 permissions: PermissionMode::Guardian,
2232 },
2233 };
2234
2235 let config = build_config_with_runtime(
2236 &[],
2237 None,
2238 Some((RuntimeKind::Podman, DEFAULT_IMAGE)),
2239 None,
2240 Some(&ssh),
2241 );
2242
2243 assert!(matches!(
2244 config.targets["podman"],
2245 TargetTemplate::LocalPodman { .. }
2246 ));
2247 assert!(matches!(
2248 config.targets["podman-2"],
2249 TargetTemplate::SshBare { .. }
2250 ));
2251 config.validate().unwrap();
2252 }
2253
2254 #[test]
2255 fn the_github_origin_is_discovered_through_the_shared_executor() {
2256 let executor = RuntimeProbeExecutor::new([ok(b"git@github.com:BrokkAi/hel.git\n")]);
2257
2258 let repository = discover_github_repository(&executor, Path::new("/work/hel")).unwrap();
2259
2260 assert_eq!(repository.source(), "BrokkAi/hel");
2261 let commands = executor.commands.borrow();
2262 assert_eq!(commands[0].program, "git");
2263 assert_eq!(
2264 commands[0].args,
2265 ["-C", "/work/hel", "remote", "get-url", "origin"]
2266 );
2267 }
2268
2269 #[test]
2270 fn no_github_origin_is_reported_when_the_probe_fails() {
2271 let failing = RuntimeProbeExecutor::new([failed(b"not a git repository")]);
2272 assert_eq!(
2273 discover_github_repository(&failing, Path::new("/work/plain")),
2274 None
2275 );
2276
2277 let missing = RuntimeProbeExecutor::new([]);
2278 assert_eq!(
2279 discover_github_repository(&missing, Path::new("/work/plain")),
2280 None
2281 );
2282 }
2283
2284 #[test]
2285 fn declining_the_aws_step_writes_no_aws_target() {
2286 let account = AwsAccount {
2287 account: "123456789012".into(),
2288 arn: "arn:aws:iam::123456789012:user/dev".into(),
2289 region: None,
2290 };
2291 let mut input = b"\n".as_slice();
2292 let mut output = Vec::new();
2293
2294 assert_eq!(
2295 prompt_aws_target(&mut input, &mut output, Some(&account)).unwrap(),
2296 None
2297 );
2298 let config = build_config_with_runtime(&[], None, None, None, None);
2299 assert!(!config.targets.contains_key(AWS_TARGET_ID));
2300 }
2301
2302 #[test]
2303 fn smoke_test_removes_the_container_after_a_failed_command() {
2304 let executor = FakeExecutor {
2305 commands: RefCell::new(vec![]),
2306 statuses: vec![0, 1, 0],
2307 };
2308 let mut output = Vec::new();
2309
2310 assert!(
2311 run_smoke_test(
2312 &mut output,
2313 &smoke_target(RuntimeKind::Podman, "ubuntu:24.04"),
2314 &executor
2315 )
2316 .is_err()
2317 );
2318 let commands = executor.commands.borrow();
2319 assert_eq!(commands.len(), 3);
2320 assert_eq!(commands[2].args[0], "rm");
2321 }
2322
2323 #[test]
2324 fn docker_smoke_test_exercises_the_managed_overlay_attachment_path() {
2325 let executor = FakeExecutor::succeeds();
2326 let mut output = Vec::new();
2327
2328 run_smoke_test(
2329 &mut output,
2330 &smoke_target(RuntimeKind::Docker, "ubuntu:24.04"),
2331 &executor,
2332 )
2333 .unwrap();
2334
2335 let commands = executor.commands.borrow();
2336 assert_eq!(commands.len(), 3);
2337 assert_eq!(commands[0].program, "sh");
2338 assert!(commands[0].args[1].contains("docker volume create"));
2339 assert!(commands[0].args[1].contains("type=overlay"));
2340 assert_eq!(commands[1].program, "docker");
2341 assert_eq!(commands[1].args[0], "exec");
2342 assert_eq!(commands[2].program, "sh");
2343 assert!(commands[2].args[1].contains("docker volume rm --force"));
2344 assert!(
2345 String::from_utf8(output)
2346 .unwrap()
2347 .contains("writable OverlayFS attachment")
2348 );
2349 }
2350
2351 #[test]
2352 fn setup_preserves_working_configuration_and_discovers_new_installations() {
2353 let directory = tempfile::tempdir().unwrap();
2354 let path = directory.path().join("config.toml");
2355 let home = DiscoveredHome {
2356 kind: HarnessKind::Codex,
2357 path: directory.path().join("codex"),
2358 authenticated: true,
2359 };
2360 let repository = GithubRepository {
2361 owner: "BrokkAi".into(),
2362 repository: "muse-acp".into(),
2363 };
2364 let mut original = build_config_with_runtimes(
2365 std::slice::from_ref(&home),
2366 Some(&repository),
2367 &[],
2368 None,
2369 None,
2370 );
2371 original.profiles.get_mut("codex").unwrap().enabled = false;
2372 original
2373 .profiles
2374 .get_mut("codex")
2375 .unwrap()
2376 .environment
2377 .insert("KEEP".into(), "custom".into());
2378 original.phone.enabled = false;
2379 original.save_to(&path).unwrap();
2380 let discovery = SetupDiscovery {
2381 homes: vec![
2382 home,
2383 DiscoveredHome {
2384 kind: HarnessKind::Muse,
2385 path: directory.path().join("muse"),
2386 authenticated: true,
2387 },
2388 ],
2389 repository: Some(GithubRepository {
2390 owner: "BrokkAi".into(),
2391 repository: "mjolnir".into(),
2392 }),
2393 ..discovery_without_runtimes()
2394 };
2395 let executor = FakeExecutor::succeeds();
2396 for _ in 0..2 {
2397 let mut output = Vec::new();
2398 run_setup_dialog_with(
2399 &mut b"y\n".as_slice(),
2400 &mut output,
2401 &path,
2402 &discovery,
2403 &executor,
2404 &executor,
2405 )
2406 .unwrap();
2407 let saved = Config::load_from(&path).unwrap();
2408 assert_eq!(saved.profiles["codex"], original.profiles["codex"]);
2409 assert_eq!(
2410 saved.bundles["current-repository"],
2411 original.bundles["current-repository"]
2412 );
2413 assert_eq!(saved.targets, original.targets);
2414 assert_eq!(saved.phone, original.phone);
2415 assert_eq!(saved.profiles.len(), 2);
2416 assert_eq!(saved.profiles["muse"].kind, HarnessKind::Muse);
2417 assert_eq!(saved.bundles.len(), 2);
2418 assert_eq!(
2419 saved.bundles["mjolnir"].repositories[0].github.as_deref(),
2420 Some("BrokkAi/mjolnir")
2421 );
2422 }
2423 }
2424
2425 #[test]
2426 fn setup_target_conflict_keeps_working_target_and_can_add_alternative() {
2427 let original = build_config_with_runtimes(
2428 &[],
2429 None,
2430 &[(RuntimeKind::Podman, "original:image")],
2431 None,
2432 None,
2433 );
2434 for (answer, expected_count) in [("\n", 1), ("a\n", 2)] {
2435 let discovered = build_config_with_runtimes(
2436 &[],
2437 None,
2438 &[(RuntimeKind::Podman, "new:image")],
2439 None,
2440 None,
2441 );
2442 let mut output = Vec::new();
2443 let additions =
2444 reconcile_setup(&mut answer.as_bytes(), &mut output, &original, discovered)
2445 .unwrap();
2446 let mut saved = original.clone();
2447 apply_setup_additions(&mut saved, &additions).unwrap();
2448 assert_eq!(saved.targets["podman"], original.targets["podman"]);
2449 assert_eq!(
2450 saved.targets.len(),
2451 original.targets.len() + expected_count - 1
2452 );
2453 assert!(
2454 String::from_utf8(output)
2455 .unwrap()
2456 .contains("Existing sessions will keep using it")
2457 );
2458 if expected_count == 2 {
2459 assert_eq!(
2460 saved.targets["podman-2"],
2461 local_runtime_target(RuntimeKind::Podman, "new:image").1
2462 );
2463 }
2464 }
2465 }
2466
2467 #[test]
2468 fn setup_reloads_concurrent_changes_and_cancellation_writes_nothing() {
2469 struct ConcurrentEdit<'a> {
2470 path: &'a Path,
2471 answer: &'a str,
2472 conflict: bool,
2473 }
2474 impl SetupPrompter for ConcurrentEdit<'_> {
2475 fn read_prompt(&mut self, _: &mut dyn Write, label: &str) -> Result<Option<String>> {
2476 assert!(label.starts_with("Write this configuration?"));
2477 Config::update_to(self.path, |config| {
2478 config.phone.enabled = false;
2479 if self.conflict {
2480 config.targets.insert(
2481 "localhost".into(),
2482 local_runtime_target(RuntimeKind::Docker, "concurrent:image").1,
2483 );
2484 }
2485 Ok(())
2486 })?;
2487 Ok(Some(self.answer.into()))
2488 }
2489 }
2490 let directory = tempfile::tempdir().unwrap();
2491 let path = directory.path().join("config.toml");
2492 let executor = FakeExecutor::succeeds();
2493 let discovery = discovery_without_runtimes();
2494 for (answer, conflict) in [("y", false), ("n", false), ("y", true)] {
2495 Config::default().save_to(&path).unwrap();
2496 let outcome = run_setup_dialog_inner(
2497 &mut ConcurrentEdit {
2498 path: &path,
2499 answer,
2500 conflict,
2501 },
2502 &mut Vec::new(),
2503 &path,
2504 &discovery,
2505 &executor,
2506 &executor,
2507 );
2508 let saved = Config::load_from(&path).unwrap();
2509 assert!(!saved.phone.enabled);
2510 if conflict {
2511 assert!(
2512 outcome
2513 .unwrap_err()
2514 .to_string()
2515 .contains("changed while setup was open")
2516 );
2517 assert_eq!(
2518 saved.targets["localhost"],
2519 local_runtime_target(RuntimeKind::Docker, "concurrent:image").1
2520 );
2521 } else if answer == "n" {
2522 assert_eq!(outcome.unwrap(), SetupOutcome::Cancelled);
2523 assert!(saved.targets.is_empty());
2524 } else {
2525 assert_eq!(outcome.unwrap(), SetupOutcome::Written);
2526 assert!(matches!(
2527 saved.targets["localhost"],
2528 TargetTemplate::LocalBare
2529 ));
2530 }
2531 }
2532 }
2533
2534 #[test]
2535 fn dialog_configures_every_usable_runtime_as_a_normal_target() {
2536 let directory = tempfile::tempdir().unwrap();
2537 let config_path = directory.path().join("config.toml");
2538 let discovery = SetupDiscovery {
2539 homes: vec![DiscoveredHome {
2540 kind: HarnessKind::Codex,
2541 path: PathBuf::from("/profiles/codex"),
2542 authenticated: true,
2543 }],
2544 repository: Some(GithubRepository {
2545 owner: "BrokkAi".into(),
2546 repository: "hel".into(),
2547 }),
2548 runtimes: vec![
2549 RuntimeProbe {
2550 kind: RuntimeKind::Podman,
2551 usable: true,
2552 detail: "podman version 5".into(),
2553 remediation: None,
2554 },
2555 RuntimeProbe {
2556 kind: RuntimeKind::Docker,
2557 usable: true,
2558 detail: "docker version 29".into(),
2559 remediation: None,
2560 },
2561 ],
2562 aws: None,
2563 ssh_hosts: vec![],
2564 };
2565 let executor = FakeExecutor::succeeds();
2566 let mut input = b"\ny\n".as_slice();
2567 let mut output = Vec::new();
2568
2569 assert_eq!(
2570 run_setup_dialog_with(
2571 &mut input,
2572 &mut output,
2573 &config_path,
2574 &discovery,
2575 &executor,
2576 &executor,
2577 )
2578 .unwrap(),
2579 SetupOutcome::Written
2580 );
2581 assert!(config_path.exists());
2582 let config = Config::load_from(&config_path).unwrap();
2583 assert!(matches!(
2584 config.targets["podman"],
2585 TargetTemplate::LocalPodman { .. }
2586 ));
2587 assert!(matches!(
2588 config.targets["docker"],
2589 TargetTemplate::LocalDocker { .. }
2590 ));
2591 let smoke = executor.commands.borrow()[..3]
2592 .iter()
2593 .map(|command| command.args[0].clone())
2594 .collect::<Vec<_>>();
2595 assert_eq!(smoke, ["run", "exec", "rm"]);
2596 let commands = executor.commands.borrow();
2597 assert!(commands.len() >= 6);
2598 assert_eq!(commands[3].program, "sh");
2599 assert_eq!(commands[4].program, "docker");
2600 assert_eq!(commands[5].program, "sh");
2601 drop(commands);
2602 let output = String::from_utf8(output).unwrap();
2603 assert!(output.contains("Podman target using"), "{output}");
2604 assert!(output.contains("Docker target using"), "{output}");
2605 assert!(!output.contains("Recommended runtime"), "{output}");
2606 assert!(!output.contains("Runtime ("), "{output}");
2607 assert!(output.ends_with("Press n to start your first session.\n"));
2608 }
2609
2610 #[test]
2611 fn a_failed_smoke_test_becomes_a_fixable_line_in_the_closing_report() {
2612 let directory = tempfile::tempdir().unwrap();
2613 let config_path = directory.path().join("config.toml");
2614 let discovery = SetupDiscovery {
2615 runtimes: vec![RuntimeProbe {
2616 kind: RuntimeKind::Podman,
2617 usable: true,
2618 detail: "podman version 5".into(),
2619 remediation: None,
2620 }],
2621 ..discovery_without_runtimes()
2622 };
2623 let executor = FakeExecutor {
2625 commands: RefCell::new(vec![]),
2626 statuses: vec![0, 1, 0],
2627 };
2628 let mut input = b"\ny\n".as_slice();
2629 let mut output = Vec::new();
2630
2631 let outcome = run_setup_dialog_with(
2632 &mut input,
2633 &mut output,
2634 &config_path,
2635 &discovery,
2636 &executor,
2637 &executor,
2638 )
2639 .unwrap();
2640
2641 assert_eq!(outcome, SetupOutcome::Written);
2642 assert!(config_path.exists());
2643 let output = String::from_utf8(output).unwrap();
2644 assert!(output.contains("fixable Podman smoke test"), "{output}");
2645 assert!(
2646 output.contains("remediation: Fix the configured image or the Podman runtime"),
2647 "{output}"
2648 );
2649 assert!(
2652 output.contains("Running `mj doctor` checks on the new config..."),
2653 "{output}"
2654 );
2655 assert!(
2656 output.contains("Apply the remediations above, then rerun `mj doctor`."),
2657 "{output}"
2658 );
2659 assert!(
2660 output.ends_with("Press n to start your first session.\n"),
2661 "{output}"
2662 );
2663 }
2664
2665 #[test]
2666 fn setup_finishes_with_the_standard_doctor_report_for_the_config_it_wrote() {
2667 let directory = tempfile::tempdir().unwrap();
2668 let config_path = directory.path().join("config.toml");
2669 let discovery = SetupDiscovery {
2670 homes: vec![DiscoveredHome {
2671 kind: HarnessKind::Codex,
2672 path: directory.path().join("missing-codex-home"),
2673 authenticated: false,
2674 }],
2675 ..discovery_without_runtimes()
2676 };
2677 let executor = FakeExecutor::succeeds();
2678 let mut input = b"y\n".as_slice();
2679 let mut output = Vec::new();
2680
2681 run_setup_dialog_with(
2682 &mut input,
2683 &mut output,
2684 &config_path,
2685 &discovery,
2686 &executor,
2687 &executor,
2688 )
2689 .unwrap();
2690
2691 let output = String::from_utf8(output).unwrap();
2692 assert!(
2695 output.contains(&format!(
2696 "ready Mjolnir configuration: {} is valid",
2697 config_path.display()
2698 )),
2699 "{output}"
2700 );
2701 assert!(output.contains("fixable Harness profile codex"), "{output}");
2702 assert!(
2703 output.contains(" remediation: Run `mj login --profile codex`"),
2704 "{output}"
2705 );
2706 assert!(
2707 output.contains("Apply the remediations above, then rerun `mj doctor`."),
2708 "{output}"
2709 );
2710 }
2711
2712 #[test]
2713 fn dialog_configures_raw_localhost_without_a_container_runtime() {
2714 let directory = tempfile::tempdir().unwrap();
2715 let config_path = directory.path().join("config.toml");
2716 let discovery = SetupDiscovery {
2717 homes: vec![DiscoveredHome {
2718 kind: HarnessKind::Kimi,
2719 path: PathBuf::from("/profiles/kimi"),
2720 authenticated: true,
2721 }],
2722 repository: None,
2723 runtimes: vec![RuntimeProbe {
2724 kind: RuntimeKind::Podman,
2725 usable: false,
2726 detail: "not installed".into(),
2727 remediation: Some("Install Podman.".into()),
2728 }],
2729 aws: None,
2730 ssh_hosts: vec![],
2731 };
2732 let executor = FakeExecutor::succeeds();
2733 let mut input = b"y\n".as_slice();
2734 let mut output = Vec::new();
2735
2736 assert_eq!(
2737 run_setup_dialog_with(
2738 &mut input,
2739 &mut output,
2740 &config_path,
2741 &discovery,
2742 &executor,
2743 &executor,
2744 )
2745 .unwrap(),
2746 SetupOutcome::Written
2747 );
2748 let config = Config::load_from(&config_path).unwrap();
2749 assert!(matches!(
2750 config.targets["localhost"],
2751 TargetTemplate::LocalBare
2752 ));
2753 assert!(
2756 executor
2757 .commands
2758 .borrow()
2759 .iter()
2760 .all(|command| command.program != "podman" || command.args[0] != "run")
2761 );
2762 let output = String::from_utf8(output).unwrap();
2763 assert!(output.contains("DANGER"));
2764 assert!(output.contains("has no guardian approval mode"));
2765 assert!(output.contains("raw localhost will still be configured"));
2766 }
2767
2768 #[test]
2769 fn discovered_homes_warn_for_harnesses_without_guardian_approvals() {
2770 let warning = |kind: HarnessKind| {
2771 let mut output = Vec::new();
2772 write_discovered_homes(
2773 &mut output,
2774 &[DiscoveredHome {
2775 kind,
2776 path: PathBuf::from("/profiles/harness"),
2777 authenticated: true,
2778 }],
2779 )
2780 .unwrap();
2781 String::from_utf8(output).unwrap()
2782 };
2783
2784 for kind in [HarnessKind::Kimi, HarnessKind::Deepseek] {
2785 let output = warning(kind);
2786 assert!(output.contains("DANGER"), "{kind:?}: {output}");
2787 assert!(
2788 output.contains("has no guardian approval mode"),
2789 "{kind:?}: {output}"
2790 );
2791 assert!(output.contains("raw, unsandboxed target"), "{output}");
2792 }
2793
2794 for kind in [HarnessKind::Codex, HarnessKind::Claude, HarnessKind::Grok] {
2795 assert!(!warning(kind).contains("DANGER"), "{kind:?}");
2796 }
2797 }
2798}