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