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::hel_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 hel::hel_config::harness_authentication_marker;
16use hel::hel_config::{
17 AwsAddressSource, ContainerTemplate, HarnessKind, HarnessProfile, HelConfig, PermissionMode,
18 ProjectBundle, ProjectRepository, SshConnection, TargetTemplate, unique_config_id as unique_id,
19 validate_id,
20};
21use hel::hel_targets::{
22 CancellableProcessExecutor, CommandExecutor, CommandSpec,
23 ContainerTemplate as RuntimeContainerTemplate, ProcessExecutor,
24 TargetTemplate as RuntimeTargetTemplate, run_setup_smoke_test,
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 = HelConfig::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 HelConfig::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 HelConfig, 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, _) = hel::hel_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(
541 kind: RuntimeKind,
542 check: crate::hel_doctor::DoctorCheck,
543) -> RuntimeProbe {
544 RuntimeProbe {
545 kind,
546 usable: check.status == CheckStatus::Ready,
547 detail: check.detail,
548 remediation: check.remediation,
549 }
550}
551
552pub fn detect_aws(executor: &impl CommandExecutor) -> Option<AwsAccount> {
558 let identity = CommandSpec::new("aws", ["sts", "get-caller-identity", "--output", "json"])
559 .purpose("detect AWS credentials");
560 let output = executor.execute(&identity).ok()?;
561 if output.status != 0 {
562 return None;
563 }
564 let identity: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
565 let account = identity.get("Account")?.as_str()?.to_owned();
566 let arn = identity.get("Arn")?.as_str()?.to_owned();
567 Some(AwsAccount {
568 account,
569 arn,
570 region: configured_aws_region(executor),
571 })
572}
573
574fn configured_aws_region(executor: &impl CommandExecutor) -> Option<String> {
575 let command = CommandSpec::new("aws", ["configure", "get", "region"])
576 .purpose("read the default AWS region");
577 let output = executor.execute(&command).ok()?;
578 if output.status != 0 {
579 return None;
580 }
581 let region = String::from_utf8_lossy(&output.stdout).trim().to_owned();
582 (!region.is_empty()).then_some(region)
583}
584
585pub fn build_config(
586 homes: &[DiscoveredHome],
587 repository: Option<&GithubRepository>,
588 runtime: RuntimeKind,
589 image: &str,
590) -> HelConfig {
591 build_config_with_runtime(homes, repository, Some((runtime, image)), None, None)
592}
593
594fn build_config_with_runtime(
595 homes: &[DiscoveredHome],
596 repository: Option<&GithubRepository>,
597 runtime: Option<(RuntimeKind, &str)>,
598 aws: Option<&AwsTargetInput>,
599 ssh: Option<&SshTargetInput>,
600) -> HelConfig {
601 build_config_with_runtimes(
602 homes,
603 repository,
604 &runtime.into_iter().collect::<Vec<_>>(),
605 aws,
606 ssh,
607 )
608}
609
610fn build_config_with_runtimes(
611 homes: &[DiscoveredHome],
612 repository: Option<&GithubRepository>,
613 runtimes: &[(RuntimeKind, &str)],
614 aws: Option<&AwsTargetInput>,
615 ssh: Option<&SshTargetInput>,
616) -> HelConfig {
617 let mut config = HelConfig::default();
618 for home in homes {
619 let id = unique_id(&config.profiles, home.kind.id());
620 config.profiles.insert(
621 id,
622 HarnessProfile {
623 enabled: true,
624 kind: home.kind,
625 home: home.path.clone(),
626 environment: BTreeMap::new(),
627 context_window_bytes: None,
628 },
629 );
630 }
631
632 if let Some(repository) = repository {
633 let repository_id = config_id(&repository.repository);
634 config.bundles.insert(
635 "current-repository".to_owned(),
636 ProjectBundle {
637 primary_repo: repository_id.clone(),
638 repositories: vec![ProjectRepository {
639 id: repository_id.clone(),
640 github: Some(repository.source()),
641 local: None,
642 destination: PathBuf::from(repository_id),
643 git_ref: None,
644 }],
645 },
646 );
647 }
648
649 #[cfg(unix)]
650 config
651 .targets
652 .insert("localhost".to_owned(), TargetTemplate::LocalBare);
653 for (runtime, image) in runtimes {
654 let (target_id, target) = local_runtime_target(*runtime, image);
655 config.targets.insert(target_id.to_owned(), target);
656 }
657 if let Some(aws) = aws {
658 config.targets.insert(
659 AWS_TARGET_ID.to_owned(),
660 TargetTemplate::AwsEc2 {
661 aws_profile: None,
662 region: aws.region.clone(),
663 launch_template: aws.launch_template.clone(),
664 launch_template_version: None,
665 ssh_user: aws.ssh_user.clone(),
666 address_source: AwsAddressSource::default(),
667 identity_file: aws.identity_file.clone(),
668 ssh_args: vec![],
669 },
670 );
671 }
672 if let Some(ssh) = ssh {
673 let connection = SshConnection {
676 host: ssh.host.clone(),
677 user: None,
678 identity_file: None,
679 extra_args: vec![],
680 };
681 let target = match &ssh.kind {
682 SshTargetKind::Bare { permissions } => TargetTemplate::SshBare {
683 ssh: connection,
684 permissions: *permissions,
685 workspace_prefix: default_ssh_workspace_prefix(),
686 },
687 SshTargetKind::Podman { image } => TargetTemplate::SshPodman {
688 ssh: connection,
689 container: ContainerTemplate {
690 image: image.clone(),
691 pull_policy: Default::default(),
692 platform: None,
693 cpus: None,
694 memory: None,
695 environment: BTreeMap::new(),
696 workspace_storage: Default::default(),
697 },
698 },
699 SshTargetKind::Docker { image } => TargetTemplate::SshDocker {
700 ssh: connection,
701 container: ContainerTemplate {
702 image: image.clone(),
703 pull_policy: Default::default(),
704 platform: None,
705 cpus: None,
706 memory: None,
707 environment: BTreeMap::new(),
708 workspace_storage: Default::default(),
709 },
710 },
711 };
712 config
716 .targets
717 .insert(unique_id(&config.targets, &ssh.name), target);
718 }
719 config
720}
721
722pub fn local_runtime_target(runtime: RuntimeKind, image: &str) -> (&'static str, TargetTemplate) {
724 let container = ContainerTemplate {
725 image: image.trim().to_owned(),
726 pull_policy: Default::default(),
727 platform: None,
728 cpus: None,
729 memory: None,
730 environment: BTreeMap::new(),
731 workspace_storage: Default::default(),
732 };
733 match runtime {
734 RuntimeKind::Podman => ("podman", TargetTemplate::LocalPodman { container }),
735 RuntimeKind::Docker => ("docker", TargetTemplate::LocalDocker { container }),
736 RuntimeKind::AppleContainer => (
737 "apple-container",
738 TargetTemplate::AppleContainer { container },
739 ),
740 }
741}
742
743fn default_ssh_workspace_prefix() -> PathBuf {
745 PathBuf::from(".local/share/hel/workspaces")
746}
747
748fn config_id(value: &str) -> String {
749 let mut id = value
750 .chars()
751 .filter(|character| {
752 character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
753 })
754 .take(64)
755 .collect::<String>();
756 if id.is_empty() || matches!(id.as_str(), "." | "..") {
757 id = "repository".to_owned();
758 }
759 id
760}
761
762pub fn run_setup_dialog_with(
769 input: &mut impl BufRead,
770 output: &mut impl Write,
771 config_path: &Path,
772 discovery: &SetupDiscovery,
773 smoke_executor: &impl CommandExecutor,
774 probe_executor: &impl CommandExecutor,
775) -> Result<SetupOutcome> {
776 run_setup_dialog_inner(
777 input,
778 output,
779 config_path,
780 discovery,
781 smoke_executor,
782 probe_executor,
783 )
784}
785
786fn run_setup_dialog_inner(
787 input: &mut impl SetupPrompter,
788 output: &mut impl Write,
789 config_path: &Path,
790 discovery: &SetupDiscovery,
791 smoke_executor: &impl CommandExecutor,
792 probe_executor: &impl CommandExecutor,
793) -> Result<SetupOutcome> {
794 let existing = HelConfig::load_from(config_path)?;
795 writeln!(output, "Welcome to Mjolnir setup.")?;
796 writeln!(output)?;
797 write_discovered_homes(output, &discovery.homes)?;
798 write_repository(output, discovery.repository.as_ref())?;
799 write_runtimes(output, &discovery.runtimes)?;
800
801 let runtimes = if discovery.runtimes.iter().any(|runtime| runtime.usable) {
802 let image = prompt(
803 input,
804 output,
805 &format!("Container image [{DEFAULT_IMAGE}]: "),
806 )?;
807 let image = if image.is_empty() {
808 DEFAULT_IMAGE.to_owned()
809 } else {
810 image
811 };
812 discovery
813 .runtimes
814 .iter()
815 .filter(|runtime| runtime.usable)
816 .map(|runtime| (runtime.kind, image.clone()))
817 .collect::<Vec<_>>()
818 } else {
819 writeln!(
820 output,
821 "No usable container runtime found; raw localhost will still be configured."
822 )?;
823 Vec::new()
824 };
825 let aws = prompt_aws_target(input, output, discovery.aws.as_ref())?;
826 let runtime_choices = runtimes
827 .iter()
828 .map(|(runtime, image)| (*runtime, image.as_str()))
829 .collect::<Vec<_>>();
830 let configured = build_config_with_runtimes(
833 &discovery.homes,
834 discovery.repository.as_ref(),
835 &runtime_choices,
836 aws.as_ref(),
837 None,
838 );
839 let ssh = prompt_ssh_target(input, output, &discovery.ssh_hosts, &configured.targets)?;
840 let config = build_config_with_runtimes(
841 &discovery.homes,
842 discovery.repository.as_ref(),
843 &runtime_choices,
844 aws.as_ref(),
845 ssh.as_ref(),
846 );
847 config.validate()?;
848 let additions = reconcile_setup(input, output, &existing, config)?;
849 let runtimes = runtimes
850 .into_iter()
851 .filter(|(runtime, image)| {
852 let (_, target) = local_runtime_target(*runtime, image);
853 additions.targets.values().any(|added| added == &target)
854 })
855 .collect::<Vec<_>>();
856
857 writeln!(output)?;
858 write_summary(output, config_path, &additions, &runtimes)?;
859 let confirmation = prompt(input, output, "Write this configuration? [y/N]: ")?;
860 if !matches!(confirmation.to_ascii_lowercase().as_str(), "y" | "yes") {
861 writeln!(output, "Setup cancelled.")?;
862 return Ok(SetupOutcome::Cancelled);
863 }
864
865 writeln!(output, "Writing {}...", config_path.display())?;
866 HelConfig::update_to(config_path, |latest| {
867 apply_setup_additions(latest, &additions)
868 })?;
869 let smoke_failures = runtimes
873 .iter()
874 .filter_map(|(runtime, image)| {
875 let target = smoke_target(*runtime, image);
876 run_smoke_test(output, &target, smoke_executor)
877 .err()
878 .map(|error| smoke_failure_check(*runtime, image, &error))
879 })
880 .collect();
881 write_doctor_report(output, config_path, probe_executor, smoke_failures)?;
882 writeln!(
883 output,
884 "Advanced users can edit TOML for extra profiles, virtual monorepos, SSH, and AWS."
885 )?;
886 writeln!(output, "Press n to start your first session.")?;
887 Ok(SetupOutcome::Written)
888}
889
890fn reconcile_setup(
892 input: &mut impl SetupPrompter,
893 output: &mut impl Write,
894 existing: &HelConfig,
895 discovered: HelConfig,
896) -> Result<HelConfig> {
897 let mut additions = existing.setup_additions(&discovered);
898 for id in additions.profiles.keys() {
899 writeln!(output, "Adding discovered profile {id}.")?;
900 }
901 for id in additions.bundles.keys() {
902 writeln!(output, "Adding repository bundle {id}.")?;
903 }
904 for (id, target) in &discovered.targets {
905 if !existing.targets.contains_key(id) {
906 continue;
907 }
908 let alternate = additions
909 .targets
910 .iter()
911 .find(|(_, added)| *added == target)
912 .map(|(id, _)| id.clone());
913 let Some(alternate) = alternate else { continue };
914 writeln!(
915 output,
916 "Target {id} already has different settings. Existing sessions will keep using it."
917 )?;
918 let answer = prompt(
919 input,
920 output,
921 &format!("Keep {id}, or add the discovered settings as {alternate}? [K/a]: "),
922 )?;
923 if !matches!(answer.to_ascii_lowercase().as_str(), "a" | "add") {
924 additions.targets.remove(&alternate);
925 writeln!(
926 output,
927 "Keeping target {id}; discovered settings were not added."
928 )?;
929 }
930 }
931 Ok(additions)
932}
933
934fn apply_setup_additions(latest: &mut HelConfig, additions: &HelConfig) -> Result<()> {
935 fn add<T: Clone>(
936 section: &str,
937 latest: &mut BTreeMap<String, T>,
938 additions: &BTreeMap<String, T>,
939 same: impl Fn(&T, &T) -> bool,
940 ) -> Result<()> {
941 for (id, value) in additions {
942 if latest.values().any(|existing| same(existing, value)) {
943 continue;
944 }
945 ensure!(
946 !latest.contains_key(id),
947 "{section} {id:?} changed while setup was open; no configuration was written. Rerun mj setup to review the current settings"
948 );
949 latest.insert(id.clone(), value.clone());
950 }
951 Ok(())
952 }
953 add(
954 "profile",
955 &mut latest.profiles,
956 &additions.profiles,
957 HarnessProfile::same_installation,
958 )?;
959 add(
960 "bundle",
961 &mut latest.bundles,
962 &additions.bundles,
963 PartialEq::eq,
964 )?;
965 add(
966 "target",
967 &mut latest.targets,
968 &additions.targets,
969 PartialEq::eq,
970 )?;
971 latest.validate()
972}
973
974fn write_discovered_homes(output: &mut impl Write, homes: &[DiscoveredHome]) -> Result<()> {
975 writeln!(output, "Harness homes:")?;
976 if homes.is_empty() {
977 writeln!(
978 output,
979 " No existing Codex, Claude Code, Kimi Code, or Grok Build homes found."
980 )?;
981 }
982 for home in homes {
983 let authentication = if home.authenticated {
984 "authenticated"
985 } else {
986 "not authenticated"
987 };
988 writeln!(
989 output,
990 " {}: {} ({authentication}){}",
991 home.kind.display_name(),
992 home.path.display(),
993 match home.kind.unsandboxed_guardian_warning() {
994 Some(warning) => format!(" — {warning}"),
995 None => String::new(),
996 }
997 )?;
998 }
999 Ok(())
1000}
1001
1002fn write_repository(output: &mut impl Write, repository: Option<&GithubRepository>) -> Result<()> {
1003 match repository {
1004 Some(repository) => writeln!(
1005 output,
1006 "GitHub origin: {} (a one-repository bundle will be created)",
1007 repository.source()
1008 )?,
1009 None => writeln!(
1010 output,
1011 "GitHub origin: none detected in the current directory."
1012 )?,
1013 }
1014 Ok(())
1015}
1016
1017fn write_runtimes(output: &mut impl Write, runtimes: &[RuntimeProbe]) -> Result<()> {
1018 writeln!(output, "Local runtimes:")?;
1019 for runtime in runtimes {
1020 let state = if runtime.usable {
1021 "usable"
1022 } else {
1023 "unavailable"
1024 };
1025 if runtime.detail.is_empty() {
1026 writeln!(output, " {}: {state}", runtime.kind.label())?;
1027 } else {
1028 writeln!(
1029 output,
1030 " {}: {state} ({})",
1031 runtime.kind.label(),
1032 runtime.detail
1033 )?;
1034 }
1035 if let Some(remediation) = &runtime.remediation {
1036 writeln!(output, " remediation: {remediation}")?;
1037 }
1038 }
1039 Ok(())
1040}
1041
1042fn prompt_aws_target(
1045 input: &mut impl SetupPrompter,
1046 output: &mut impl Write,
1047 account: Option<&AwsAccount>,
1048) -> Result<Option<AwsTargetInput>> {
1049 let Some(account) = account else {
1050 writeln!(
1051 output,
1052 "AWS: no working `aws` CLI credentials found; skipping the AWS target."
1053 )?;
1054 return Ok(None);
1055 };
1056 writeln!(
1057 output,
1058 "AWS: credentials are valid for account {} ({}).",
1059 account.account, account.arn
1060 )?;
1061 let answer = prompt(input, output, "Add an AWS EC2 target? [y/N]: ")?;
1062 if !matches!(answer.to_ascii_lowercase().as_str(), "y" | "yes") {
1063 return Ok(None);
1064 }
1065
1066 let launch_template = prompt(input, output, "Launch template name: ")?;
1067 if launch_template.is_empty() {
1068 writeln!(
1069 output,
1070 "A launch template name is required; skipping the AWS target."
1071 )?;
1072 return Ok(None);
1073 }
1074
1075 let region_label = match &account.region {
1076 Some(region) => format!("Region [{region}]: "),
1077 None => "Region: ".to_owned(),
1078 };
1079 let region = prompt(input, output, ®ion_label)?;
1080 let region = if region.is_empty() {
1081 match &account.region {
1082 Some(region) => region.clone(),
1083 None => {
1084 writeln!(output, "A region is required; skipping the AWS target.")?;
1085 return Ok(None);
1086 }
1087 }
1088 } else {
1089 region
1090 };
1091
1092 let ssh_user = prompt(
1093 input,
1094 output,
1095 &format!("SSH user [{DEFAULT_AWS_SSH_USER}]: "),
1096 )?;
1097 let ssh_user = if ssh_user.is_empty() {
1098 DEFAULT_AWS_SSH_USER.to_owned()
1099 } else {
1100 ssh_user
1101 };
1102 let identity_file = prompt(input, output, "SSH identity file (optional): ")?;
1103
1104 Ok(Some(AwsTargetInput {
1105 launch_template,
1106 region,
1107 ssh_user,
1108 identity_file: (!identity_file.is_empty()).then(|| PathBuf::from(identity_file)),
1109 }))
1110}
1111
1112fn prompt_ssh_target(
1117 input: &mut impl SetupPrompter,
1118 output: &mut impl Write,
1119 aliases: &[String],
1120 configured: &BTreeMap<String, TargetTemplate>,
1121) -> Result<Option<SshTargetInput>> {
1122 if aliases.is_empty() {
1123 writeln!(
1124 output,
1125 "SSH: no host aliases found in ~/.ssh/config; skipping the SSH target."
1126 )?;
1127 return Ok(None);
1128 }
1129 writeln!(output, "SSH: hosts found in ~/.ssh/config:")?;
1130 for (index, alias) in aliases.iter().enumerate() {
1131 writeln!(output, " {}) {alias}", index + 1)?;
1132 }
1133 let answer = prompt(input, output, "Add an SSH target? [y/N]: ")?;
1134 if !matches!(answer.to_ascii_lowercase().as_str(), "y" | "yes") {
1135 return Ok(None);
1136 }
1137
1138 let choice = prompt(
1139 input,
1140 output,
1141 &format!("Host number 1-{} or a host name: ", aliases.len()),
1142 )?;
1143 let host = match choice.parse::<usize>() {
1144 Ok(index) if (1..=aliases.len()).contains(&index) => aliases[index - 1].clone(),
1145 _ if !choice.is_empty() => choice,
1146 _ => {
1147 writeln!(output, "A host is required; skipping the SSH target.")?;
1148 return Ok(None);
1149 }
1150 };
1151
1152 let kind = loop {
1153 let runtime = prompt(
1154 input,
1155 output,
1156 "Container runtime on that host, podman, docker, or bare [podman]: ",
1157 )?;
1158 let runtime = runtime.to_ascii_lowercase();
1159 if matches!(runtime.as_str(), "n" | "no" | "bare") {
1160 let permissions = loop {
1161 let mode = prompt(
1162 input,
1163 output,
1164 "Raw-host permissions, guardian or yolo [guardian]: ",
1165 )?;
1166 match mode.to_ascii_lowercase().as_str() {
1167 "" | "guardian" => break PermissionMode::Guardian,
1168 "yolo" => break PermissionMode::Yolo,
1169 _ => writeln!(output, "Permissions must be `guardian` or `yolo`.")?,
1170 }
1171 };
1172 break SshTargetKind::Bare { permissions };
1173 }
1174 let kind = if matches!(runtime.as_str(), "" | "y" | "yes" | "podman") {
1175 SshTargetKind::Podman {
1176 image: String::new(),
1177 }
1178 } else if runtime == "docker" {
1179 SshTargetKind::Docker {
1180 image: String::new(),
1181 }
1182 } else {
1183 writeln!(
1184 output,
1185 "Runtime must be `podman`, `docker`, or `bare`; please choose again."
1186 )?;
1187 continue;
1188 };
1189 let image = prompt(
1190 input,
1191 output,
1192 &format!("Container image [{DEFAULT_IMAGE}]: "),
1193 )?;
1194 let image = if image.is_empty() {
1195 DEFAULT_IMAGE.to_owned()
1196 } else {
1197 image
1198 };
1199 break match kind {
1200 SshTargetKind::Podman { .. } => SshTargetKind::Podman { image },
1201 SshTargetKind::Docker { .. } => SshTargetKind::Docker { image },
1202 SshTargetKind::Bare { .. } => unreachable!("bare runtime returned above"),
1203 };
1204 };
1205
1206 let Some(name) = prompt_ssh_target_name(input, output, &host, configured)? else {
1207 return Ok(None);
1208 };
1209
1210 Ok(Some(SshTargetInput { name, host, kind }))
1211}
1212
1213fn prompt_ssh_target_name(
1220 input: &mut impl SetupPrompter,
1221 output: &mut impl Write,
1222 host: &str,
1223 configured: &BTreeMap<String, TargetTemplate>,
1224) -> Result<Option<String>> {
1225 loop {
1226 let Some(answer) = prompt_line(input, output, &format!("Target name [{host}]: "))? else {
1227 writeln!(output, "Input ended; skipping the SSH target.")?;
1228 return Ok(None);
1229 };
1230 let name = if answer.is_empty() {
1231 host.to_owned()
1232 } else {
1233 answer
1234 };
1235 if let Err(error) = validate_id("target", &name) {
1236 writeln!(output, "{error}")?;
1237 continue;
1238 }
1239 if configured.contains_key(&name) {
1240 writeln!(
1241 output,
1242 "Target {name} is already configured; choose another name."
1243 )?;
1244 continue;
1245 }
1246 return Ok(Some(name));
1247 }
1248}
1249
1250fn smoke_failure_check(runtime: RuntimeKind, image: &str, error: &anyhow::Error) -> DoctorCheck {
1253 let scope = match runtime {
1254 RuntimeKind::Docker => "Disposable run/exec/remove and OverlayFS attachment smoke test",
1255 RuntimeKind::Podman | RuntimeKind::AppleContainer => {
1256 "Disposable run/exec/remove smoke test"
1257 }
1258 };
1259 DoctorCheck::fixable(
1260 format!("runtime.{}.smoke", runtime.id()),
1261 format!("{} smoke test", runtime.label()),
1262 format!("{scope} failed for image {image}: {error:#}"),
1263 format!(
1264 "Fix the configured image or the {} runtime, then run `mj doctor --smoke` again.",
1265 runtime.label()
1266 ),
1267 )
1268}
1269
1270fn write_doctor_report(
1276 output: &mut impl Write,
1277 config_path: &Path,
1278 executor: &impl CommandExecutor,
1279 extra: Vec<DoctorCheck>,
1280) -> Result<()> {
1281 writeln!(output)?;
1282 writeln!(output, "Running `mj doctor` checks on the new config...")?;
1283 let mut checks = run_with_config_path(
1284 config_path,
1285 executor,
1286 current_apple_platform(executor),
1287 DoctorOptions { smoke: false },
1288 );
1289 checks.extend(extra);
1290 render_human(&checks, output)?;
1291 if all_ready(&checks) {
1292 writeln!(output, "Every check is ready.")?;
1293 } else {
1294 writeln!(
1295 output,
1296 "Apply the remediations above, then rerun `mj doctor`."
1297 )?;
1298 }
1299 Ok(())
1300}
1301
1302fn prompt(input: &mut impl SetupPrompter, output: &mut impl Write, label: &str) -> Result<String> {
1303 Ok(prompt_line(input, output, label)?.unwrap_or_default())
1304}
1305
1306fn prompt_line(
1312 input: &mut impl SetupPrompter,
1313 output: &mut impl Write,
1314 label: &str,
1315) -> Result<Option<String>> {
1316 input.read_prompt(output, label)
1317}
1318
1319trait SetupPrompter {
1320 fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>>;
1321}
1322
1323impl<R: BufRead> SetupPrompter for R {
1324 fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>> {
1325 write!(output, "{label}")?;
1326 output.flush()?;
1327 let mut answer = String::new();
1328 let read = self.read_line(&mut answer).context("read setup response")?;
1329 Ok((read > 0).then(|| answer.trim().to_owned()))
1330 }
1331}
1332
1333#[derive(Default)]
1334struct ReadlinePrompter(crate::hel_readline::LineReader);
1335
1336impl SetupPrompter for ReadlinePrompter {
1337 fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>> {
1338 output.flush()?;
1339 self.0.read_line(label).context("read setup response")
1340 }
1341}
1342
1343fn write_summary(
1344 output: &mut impl Write,
1345 config_path: &Path,
1346 config: &HelConfig,
1347 runtimes: &[(RuntimeKind, String)],
1348) -> Result<()> {
1349 writeln!(output, "Mjolnir will add to {}:", config_path.display())?;
1350 writeln!(output, " {} profile(s)", config.profiles.len())?;
1351 writeln!(output, " {} bundle(s)", config.bundles.len())?;
1352 if config
1353 .targets
1354 .values()
1355 .any(|target| matches!(target, TargetTemplate::LocalBare))
1356 {
1357 writeln!(
1358 output,
1359 " raw localhost target using configured harness homes directly"
1360 )?;
1361 }
1362 for (runtime, image) in runtimes {
1363 writeln!(output, " {} target using {image}", runtime.label())?;
1364 }
1365 for id in config.targets.keys() {
1366 writeln!(output, " target id: {id}")?;
1367 }
1368 if let Some(TargetTemplate::AwsEc2 {
1369 launch_template,
1370 region,
1371 ..
1372 }) = config.targets.get(AWS_TARGET_ID)
1373 {
1374 writeln!(
1375 output,
1376 " AWS EC2 target using launch template {launch_template} in {region}"
1377 )?;
1378 }
1379 for (id, target) in &config.targets {
1380 match target {
1381 TargetTemplate::SshBare { ssh, .. } => {
1382 writeln!(output, " SSH target {id} on {} (no container)", ssh.host)?;
1383 }
1384 TargetTemplate::SshPodman { ssh, container, .. } => {
1385 writeln!(
1386 output,
1387 " SSH target {id} on {} using Podman image {}",
1388 ssh.host, container.image
1389 )?;
1390 }
1391 TargetTemplate::SshDocker { ssh, container } => {
1392 writeln!(
1393 output,
1394 " SSH target {id} on {} using Docker image {}",
1395 ssh.host, container.image
1396 )?;
1397 }
1398 _ => {}
1399 }
1400 }
1401 if config_path.exists() {
1402 writeln!(
1403 output,
1404 " Existing profiles, bundles, targets, and preferences will be preserved."
1405 )?;
1406 }
1407 Ok(())
1408}
1409
1410fn smoke_target(runtime: RuntimeKind, image: &str) -> RuntimeTargetTemplate {
1411 let container = RuntimeContainerTemplate {
1412 image: image.to_owned(),
1413 pull_policy: Default::default(),
1414 extra_run_args: vec![],
1415 workspace_storage: Default::default(),
1416 };
1417 match runtime {
1418 RuntimeKind::Podman => RuntimeTargetTemplate::LocalPodman(container),
1419 RuntimeKind::Docker => RuntimeTargetTemplate::LocalDocker(container),
1420 RuntimeKind::AppleContainer => RuntimeTargetTemplate::AppleContainer(container),
1421 }
1422}
1423
1424fn run_smoke_test(
1425 output: &mut impl Write,
1426 target: &RuntimeTargetTemplate,
1427 executor: &impl CommandExecutor,
1428) -> Result<()> {
1429 let smoke_id = format!(
1430 "setup-{}-{:x}",
1431 std::process::id(),
1432 SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos()
1433 );
1434 let description = match target {
1435 RuntimeTargetTemplate::LocalDocker(_) => {
1436 "Smoke test: verifying a disposable container and writable OverlayFS attachment..."
1437 }
1438 _ => "Smoke test: verifying a disposable container...",
1439 };
1440 writeln!(output, "{description}")?;
1441 run_setup_smoke_test(target, &smoke_id, executor)
1442}
1443
1444#[cfg(test)]
1445mod tests {
1446 use std::cell::RefCell;
1447 use std::fs;
1448
1449 use super::*;
1450 use hel::hel_targets::CommandOutput;
1451
1452 #[cfg(unix)]
1453 #[test]
1454 fn first_terminal_launch_writes_a_local_codex_config_once() {
1455 let directory = tempfile::tempdir().unwrap();
1456 let path = directory.path().join("config.toml");
1457 initialize_local_startup_config(&path).unwrap();
1458 let config = HelConfig::load_from(&path).unwrap();
1459 assert_eq!(config.profiles["codex"].kind, HarnessKind::Codex);
1460 assert!(config.profiles["codex"].home.is_absolute());
1461 assert!(matches!(
1462 config.targets["localhost"],
1463 TargetTemplate::LocalBare
1464 ));
1465 assert!(config.bundles.is_empty());
1466 let written = fs::read(&path).unwrap();
1467 initialize_local_startup_config(&path).unwrap();
1468 assert_eq!(fs::read(&path).unwrap(), written);
1469 }
1470
1471 #[cfg(unix)]
1472 #[test]
1473 fn local_startup_preserves_existing_settings_and_ignores_disabled_startup() {
1474 let directory = tempfile::tempdir().unwrap();
1475 let path = directory.path().join("config.toml");
1476 let mut config = HelConfig::default();
1477 config.phone.enabled = false;
1478 config.save_to(&path).unwrap();
1479 initialize_local_startup_config(&path).unwrap();
1480 assert!(!HelConfig::load_from(&path).unwrap().phone.enabled);
1481
1482 let configured =
1484 "version = 2\n# keep this comment\n[targets.custom]\nkind = 'local-bare'\n";
1485 fs::write(&path, configured).unwrap();
1486 initialize_local_startup_config(&path).unwrap();
1487 assert_eq!(fs::read_to_string(&path).unwrap(), configured);
1488
1489 let disabled = "version = 2\n[startup]\nenabled = false\n";
1490 fs::write(&path, disabled).unwrap();
1491 initialize_local_startup_config(&path).unwrap();
1492 let bootstrapped = HelConfig::load_from(&path).unwrap();
1493 assert!(!bootstrapped.startup.enabled);
1494 assert_eq!(bootstrapped.profiles["codex"].kind, HarnessKind::Codex);
1495 assert!(matches!(
1496 bootstrapped.targets["localhost"],
1497 TargetTemplate::LocalBare
1498 ));
1499
1500 let newer = "version = 999\nfuture_field = true\n";
1501 fs::write(&path, newer).unwrap();
1502 initialize_local_startup_config(&path).unwrap();
1503 assert_eq!(fs::read_to_string(&path).unwrap(), newer);
1504 }
1505
1506 struct FakeExecutor {
1507 commands: RefCell<Vec<CommandSpec>>,
1508 statuses: Vec<i32>,
1509 }
1510
1511 impl FakeExecutor {
1512 fn succeeds() -> Self {
1513 Self {
1514 commands: RefCell::new(vec![]),
1515 statuses: vec![0, 0, 0],
1516 }
1517 }
1518 }
1519
1520 impl CommandExecutor for FakeExecutor {
1521 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1522 let index = self.commands.borrow().len();
1523 self.commands.borrow_mut().push(command.clone());
1524 Ok(CommandOutput {
1525 status: self.statuses.get(index).copied().unwrap_or(0),
1526 stdout: b"available".to_vec(),
1527 stderr: b"failed".to_vec(),
1528 })
1529 }
1530 }
1531
1532 struct RuntimeProbeExecutor {
1533 commands: RefCell<Vec<CommandSpec>>,
1534 outputs: RefCell<Vec<CommandOutput>>,
1535 }
1536
1537 impl RuntimeProbeExecutor {
1538 fn new(outputs: impl IntoIterator<Item = CommandOutput>) -> Self {
1539 Self {
1540 commands: RefCell::new(vec![]),
1541 outputs: RefCell::new(outputs.into_iter().collect()),
1542 }
1543 }
1544 }
1545
1546 impl CommandExecutor for RuntimeProbeExecutor {
1547 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1548 self.commands.borrow_mut().push(command.clone());
1549 if self.outputs.borrow().is_empty() {
1550 anyhow::bail!("no canned output for {}", command.program);
1551 }
1552 Ok(self.outputs.borrow_mut().remove(0))
1553 }
1554 }
1555
1556 fn ok(stdout: &[u8]) -> CommandOutput {
1557 CommandOutput {
1558 status: 0,
1559 stdout: stdout.to_vec(),
1560 stderr: vec![],
1561 }
1562 }
1563
1564 fn failed(stderr: &[u8]) -> CommandOutput {
1565 CommandOutput {
1566 status: 1,
1567 stdout: vec![],
1568 stderr: stderr.to_vec(),
1569 }
1570 }
1571
1572 const CALLER_IDENTITY: &[u8] =
1573 br#"{"UserId":"AIDA","Account":"123456789012","Arn":"arn:aws:iam::123456789012:user/dev"}"#;
1574
1575 fn discovery_without_runtimes() -> SetupDiscovery {
1576 SetupDiscovery {
1577 homes: vec![],
1578 repository: None,
1579 runtimes: vec![],
1580 aws: None,
1581 ssh_hosts: vec![],
1582 }
1583 }
1584
1585 #[test]
1586 fn newly_installed_harness_is_discovered_before_its_first_login() {
1587 struct InstalledMuse;
1588 impl CommandExecutor for InstalledMuse {
1589 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1590 assert_eq!(command.args, ["--version"]);
1591 Ok(CommandOutput {
1592 status: if command.program == "muse" { 0 } else { 127 },
1593 stdout: Vec::new(),
1594 stderr: Vec::new(),
1595 })
1596 }
1597 }
1598 let directory = tempfile::tempdir().unwrap();
1599 let path = directory.path().join("custom-muse-home");
1600 let overrides = BTreeMap::from([(HarnessKind::Muse, path.clone())]);
1601 let mut homes = Vec::new();
1602 for _ in 0..2 {
1603 discover_installed_harnesses(
1604 Some(directory.path()),
1605 &overrides,
1606 &mut homes,
1607 &InstalledMuse,
1608 );
1609 assert_eq!(
1610 homes,
1611 vec![DiscoveredHome {
1612 kind: HarnessKind::Muse,
1613 path: path.clone(),
1614 authenticated: false
1615 }]
1616 );
1617 assert!(!path.exists(), "discovery must not create a profile");
1618 }
1619 }
1620
1621 #[test]
1622 fn discovers_default_and_overridden_homes_with_authentication_markers() {
1623 let directory = tempfile::tempdir().unwrap();
1624 let home = directory.path().join("home");
1625 let codex = home.join(".codex");
1626 let kimi = home.join(".kimi-code");
1627 let grok = home.join(".grok");
1628 let deepseek = home.join(".dsh");
1629 let claude = directory.path().join("claude-override");
1630 fs::create_dir_all(&codex).unwrap();
1631 fs::create_dir_all(kimi.join("credentials")).unwrap();
1632 fs::create_dir_all(&grok).unwrap();
1633 fs::create_dir_all(&deepseek).unwrap();
1634 fs::create_dir_all(&claude).unwrap();
1635 fs::write(codex.join("auth.json"), "{}").unwrap();
1636 fs::write(kimi.join("credentials/kimi-code.json"), "{}").unwrap();
1637 fs::write(grok.join("auth.json"), "{}").unwrap();
1638 fs::write(deepseek.join(".credentials.yaml"), "version: 1\n").unwrap();
1639 fs::write(claude.join(".credentials.json"), "{}").unwrap();
1640
1641 let executor = FakeExecutor::succeeds();
1642 let homes = discover_harness_homes_with_executor(
1643 Some(&home),
1644 [(HarnessKind::Claude, claude.clone())],
1645 &executor,
1646 );
1647
1648 assert_eq!(homes.len(), 5);
1649 assert!(homes.iter().all(|home| home.authenticated));
1650 assert!(homes.iter().any(|home| home.path == codex));
1651 assert!(homes.iter().any(|home| home.path == claude));
1652 assert!(homes.iter().any(|home| home.path == kimi));
1653 assert!(
1654 homes
1655 .iter()
1656 .any(|home| { home.path == deepseek && home.kind == HarnessKind::Deepseek })
1657 );
1658 assert!(
1659 homes
1660 .iter()
1661 .any(|home| home.path == grok && home.kind == HarnessKind::Grok)
1662 );
1663 }
1664
1665 #[test]
1666 fn every_harness_has_a_discoverable_default_home() {
1667 let directory = tempfile::tempdir().unwrap();
1668 let home = directory.path().to_path_buf();
1669 for kind in HarnessKind::ALL {
1670 fs::create_dir_all(home.join(kind.default_home_leaf())).unwrap();
1671 }
1672
1673 let executor = FakeExecutor::succeeds();
1674 let homes = discover_harness_homes_with_executor(Some(&home), [], &executor);
1675
1676 assert_eq!(homes.len(), HarnessKind::ALL.len());
1677 for kind in HarnessKind::ALL {
1678 assert!(
1679 homes
1680 .iter()
1681 .any(|home| home.kind == kind && !home.authenticated),
1682 "{kind:?} default home"
1683 );
1684 }
1685 }
1686
1687 #[cfg(target_os = "macos")]
1688 #[test]
1689 fn claude_keychain_marks_the_default_home_authenticated_without_a_marker() {
1690 let directory = tempfile::tempdir().unwrap();
1691 let home = directory.path().join("home");
1692 let claude = home.join(".claude");
1693 fs::create_dir_all(&claude).unwrap();
1694 let executor = RuntimeProbeExecutor::new([ok(
1695 br#"{"claudeAiOauth":{"accessToken":"access","refreshToken":"refresh"}}"#,
1696 )]);
1697
1698 let homes = discover_harness_homes_with_executor(Some(&home), [], &executor);
1699
1700 assert_eq!(
1701 homes,
1702 vec![DiscoveredHome {
1703 kind: HarnessKind::Claude,
1704 path: claude.clone(),
1705 authenticated: true,
1706 }]
1707 );
1708 let commands = executor.commands.borrow();
1709 assert_eq!(commands.len(), 1);
1710 assert_eq!(commands[0].program, "security");
1711 assert_eq!(
1712 commands[0].args,
1713 [
1714 "find-generic-password",
1715 "-s",
1716 "Claude Code-credentials",
1717 "-w"
1718 ]
1719 );
1720 assert!(commands[0].env.is_empty());
1721 }
1722
1723 #[test]
1724 fn claude_status_checks_a_custom_home_without_a_marker() {
1725 let directory = tempfile::tempdir().unwrap();
1726 let claude = directory.path().join("claude-custom");
1727 fs::create_dir_all(&claude).unwrap();
1728 let executor =
1729 RuntimeProbeExecutor::new([ok(br#"{"loggedIn":true,"authMethod":"claude.ai"}"#)]);
1730
1731 let homes = discover_harness_homes_with_executor(
1732 None,
1733 [(HarnessKind::Claude, claude.clone())],
1734 &executor,
1735 );
1736
1737 assert_eq!(
1738 homes,
1739 vec![DiscoveredHome {
1740 kind: HarnessKind::Claude,
1741 path: claude.clone(),
1742 authenticated: true,
1743 }]
1744 );
1745 let commands = executor.commands.borrow();
1746 assert_eq!(commands.len(), 1);
1747 assert_eq!(commands[0].program, "claude");
1748 assert_eq!(commands[0].args, ["auth", "status", "--json"]);
1749 assert_eq!(
1750 commands[0].env.get("CLAUDE_CONFIG_DIR"),
1751 Some(&claude.to_string_lossy().into_owned())
1752 );
1753 }
1754
1755 #[test]
1756 fn claude_credential_evidence_requires_a_nonempty_login_secret() {
1757 assert!(claude_credentials_contain_login(
1758 br#"{"claudeAiOauth":{"refreshToken":"refresh"}}"#
1759 ));
1760 assert!(!claude_credentials_contain_login(
1761 br#"{"claudeAiOauth":{"refreshToken":" "}}"#
1762 ));
1763 assert!(!claude_credentials_contain_login(b"not json"));
1764 }
1765
1766 #[test]
1767 fn github_origin_parser_accepts_standard_https_and_ssh_forms() {
1768 for origin in [
1769 "https://github.com/BrokkAi/hel.git",
1770 "git@github.com:BrokkAi/hel.git",
1771 "ssh://git@github.com/BrokkAi/hel.git",
1772 ] {
1773 assert_eq!(
1774 github_repository_from_origin(origin),
1775 Some(GithubRepository {
1776 owner: "BrokkAi".into(),
1777 repository: "hel".into(),
1778 })
1779 );
1780 }
1781 assert_eq!(
1782 github_repository_from_origin("https://example.com/hel"),
1783 None
1784 );
1785 }
1786
1787 #[test]
1788 fn config_contains_discovered_profiles_current_repository_and_selected_target() {
1789 let homes = vec![
1790 DiscoveredHome {
1791 kind: HarnessKind::Codex,
1792 path: PathBuf::from("/profiles/codex"),
1793 authenticated: true,
1794 },
1795 DiscoveredHome {
1796 kind: HarnessKind::Codex,
1797 path: PathBuf::from("/profiles/codex-two"),
1798 authenticated: false,
1799 },
1800 ];
1801 let repository = GithubRepository {
1802 owner: "BrokkAi".into(),
1803 repository: "hel".into(),
1804 };
1805
1806 let config = build_config(
1807 &homes,
1808 Some(&repository),
1809 RuntimeKind::Podman,
1810 "ubuntu:24.04",
1811 );
1812
1813 config.validate().unwrap();
1814 assert!(config.profiles.contains_key("codex"));
1815 assert!(config.profiles.contains_key("codex-2"));
1816 assert_eq!(
1817 config.bundles["current-repository"].repositories[0]
1818 .github
1819 .as_deref(),
1820 Some("BrokkAi/hel")
1821 );
1822 assert!(matches!(
1823 config.targets["podman"],
1824 TargetTemplate::LocalPodman { .. }
1825 ));
1826 assert!(matches!(
1827 config.targets["localhost"],
1828 TargetTemplate::LocalBare
1829 ));
1830
1831 let docker = build_config(
1832 &homes,
1833 Some(&repository),
1834 RuntimeKind::Docker,
1835 "ubuntu:24.04",
1836 );
1837 assert!(matches!(
1838 docker.targets["docker"],
1839 TargetTemplate::LocalDocker { .. }
1840 ));
1841 }
1842
1843 #[test]
1844 fn runtime_probe_requires_podman_rootless_preflight_and_checks_apple_on_macos() {
1845 let executor = RuntimeProbeExecutor::new([
1846 ok(b"podman version 5.4.2\n"),
1847 ok(b"true\n"),
1848 ok(b"0 1000 1\n1 100000 65536\n"),
1849 ok(b"29.0.1 linux\n"),
1850 ok(b"container version 1\n"),
1851 ok(b"running\n"),
1852 ]);
1853 let runtimes = probe_local_runtimes(&executor, true);
1854
1855 assert_eq!(runtimes.len(), 3);
1856 assert_eq!(executor.commands.borrow()[0].program, "podman");
1857 assert_eq!(executor.commands.borrow()[0].args, ["--version"]);
1858 assert_eq!(
1859 executor.commands.borrow()[1].args,
1860 ["info", "--format", "{{.Host.Security.Rootless}}"]
1861 );
1862 assert_eq!(
1863 executor.commands.borrow()[2].args,
1864 ["unshare", "cat", "/proc/self/uid_map"]
1865 );
1866 assert_eq!(executor.commands.borrow()[3].program, "docker");
1867 assert_eq!(executor.commands.borrow()[4].program, "container");
1868 assert!(runtimes.iter().all(|runtime| runtime.usable));
1869 }
1870
1871 #[test]
1872 fn unusable_podman_carries_the_doctor_remediation_into_the_runtime_list() {
1873 let executor = RuntimeProbeExecutor::new([
1874 ok(b"podman version 3.4.7\n"),
1875 failed(b"docker is unavailable"),
1876 ]);
1877
1878 let runtimes = probe_local_runtimes(&executor, false);
1879
1880 assert_eq!(runtimes.len(), 2);
1881 assert!(!runtimes[0].usable);
1882 let remediation = runtimes[0].remediation.as_deref().unwrap();
1883 assert!(remediation.contains("Upgrade Podman"), "{remediation}");
1884
1885 let mut output = Vec::new();
1886 write_runtimes(&mut output, &runtimes).unwrap();
1887 let output = String::from_utf8(output).unwrap();
1888 assert!(output.contains("Podman: unavailable"), "{output}");
1889 assert!(output.contains("Docker: unavailable"), "{output}");
1890 assert!(output.contains("remediation: Upgrade Podman"), "{output}");
1891 }
1892
1893 #[test]
1894 fn aws_is_detected_only_when_the_caller_identity_call_succeeds() {
1895 let missing = RuntimeProbeExecutor::new([]);
1896 assert_eq!(detect_aws(&missing), None);
1897
1898 let denied = RuntimeProbeExecutor::new([failed(b"ExpiredToken")]);
1899 assert_eq!(detect_aws(&denied), None);
1900
1901 let working = RuntimeProbeExecutor::new([ok(CALLER_IDENTITY), ok(b"us-east-1\n")]);
1902 assert_eq!(
1903 detect_aws(&working),
1904 Some(AwsAccount {
1905 account: "123456789012".into(),
1906 arn: "arn:aws:iam::123456789012:user/dev".into(),
1907 region: Some("us-east-1".into()),
1908 })
1909 );
1910 assert_eq!(working.commands.borrow()[0].args[0], "sts");
1911 assert_eq!(
1912 working.commands.borrow()[1].args,
1913 ["configure", "get", "region"]
1914 );
1915 }
1916
1917 #[test]
1918 fn aws_detection_without_a_configured_region_leaves_the_region_unset() {
1919 let executor = RuntimeProbeExecutor::new([ok(CALLER_IDENTITY), failed(b"")]);
1920
1921 assert_eq!(detect_aws(&executor).unwrap().region, None);
1922 }
1923
1924 #[test]
1925 fn the_aws_step_asks_nothing_when_no_aws_credentials_were_detected() {
1926 let mut input = b"".as_slice();
1927 let mut output = Vec::new();
1928
1929 let aws = prompt_aws_target(&mut input, &mut output, None).unwrap();
1930
1931 assert_eq!(aws, None);
1932 let output = String::from_utf8(output).unwrap();
1933 assert!(output.contains("skipping the AWS target"), "{output}");
1934 assert!(!output.contains("[y/N]"), "{output}");
1935 }
1936
1937 #[test]
1938 fn the_aws_step_defaults_region_and_ssh_user_when_the_answers_are_blank() {
1939 let account = AwsAccount {
1940 account: "123456789012".into(),
1941 arn: "arn:aws:iam::123456789012:user/dev".into(),
1942 region: Some("us-east-1".into()),
1943 };
1944 let mut input = b"y\nhel-runson\n\n\n\n".as_slice();
1945 let mut output = Vec::new();
1946
1947 let aws = prompt_aws_target(&mut input, &mut output, Some(&account))
1948 .unwrap()
1949 .unwrap();
1950
1951 assert_eq!(
1952 aws,
1953 AwsTargetInput {
1954 launch_template: "hel-runson".into(),
1955 region: "us-east-1".into(),
1956 ssh_user: DEFAULT_AWS_SSH_USER.into(),
1957 identity_file: None,
1958 }
1959 );
1960 let config = build_config_with_runtime(&[], None, None, Some(&aws), None);
1961 let TargetTemplate::AwsEc2 {
1962 region,
1963 launch_template,
1964 ssh_user,
1965 ..
1966 } = &config.targets[AWS_TARGET_ID]
1967 else {
1968 panic!("setup must write an aws-ec2 target");
1969 };
1970 assert_eq!(region, "us-east-1");
1971 assert_eq!(launch_template, "hel-runson");
1972 assert_eq!(ssh_user, DEFAULT_AWS_SSH_USER);
1973 config.validate().unwrap();
1974 }
1975
1976 const SSH_CONFIG_FIXTURE: &str = r#"
1977# Personal hosts
1978Host *
1979 ServerAliveInterval 60
1980
1981Host builder build.example.com
1982 HostName build.example.com
1983 User dev
1984
1985Host bastion
1986 HostName 10.0.0.1
1987 IdentityFile ~/.ssh/id_ed25519
1988
1989Host prod-*
1990 User deploy
1991
1992Host !staging *.internal
1993 User deploy
1994
1995Host builder
1996 Compression yes
1997"#;
1998
1999 #[test]
2000 fn ssh_config_parsing_keeps_concrete_aliases_and_drops_pattern_blocks() {
2001 let aliases = ssh_config_aliases(SSH_CONFIG_FIXTURE);
2002
2003 assert_eq!(
2004 aliases,
2005 vec!["builder", "build.example.com", "bastion"],
2006 "wildcard, negated, and duplicate entries must not appear"
2007 );
2008 }
2009
2010 #[test]
2011 fn ssh_config_parsing_returns_nothing_for_a_config_of_only_wildcards() {
2012 assert!(
2013 ssh_config_aliases(
2014 "Host *
2015 User dev
2016"
2017 )
2018 .is_empty()
2019 );
2020 assert!(ssh_config_aliases("").is_empty());
2021 }
2022
2023 #[test]
2024 fn the_ssh_step_asks_nothing_when_the_ssh_config_has_no_aliases() {
2025 let mut input = b"".as_slice();
2026 let mut output = Vec::new();
2027
2028 assert_eq!(
2029 prompt_ssh_target(&mut input, &mut output, &[], &BTreeMap::new()).unwrap(),
2030 None
2031 );
2032 let output = String::from_utf8(output).unwrap();
2033 assert!(output.contains("skipping the SSH target"), "{output}");
2034 assert!(!output.contains("[y/N]"), "{output}");
2035 }
2036
2037 #[test]
2038 fn declining_the_ssh_step_writes_no_ssh_target() {
2039 let aliases = vec!["builder".to_owned()];
2040 let mut input = b"\n".as_slice();
2041 let mut output = Vec::new();
2042
2043 assert_eq!(
2044 prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new()).unwrap(),
2045 None
2046 );
2047 let config = build_config_with_runtime(&[], None, None, None, None);
2048 assert!(!config.targets.values().any(|target| matches!(
2049 target,
2050 TargetTemplate::SshBare { .. }
2051 | TargetTemplate::SshPodman { .. }
2052 | TargetTemplate::SshDocker { .. }
2053 )));
2054 }
2055
2056 #[test]
2057 fn accepting_the_ssh_step_can_write_a_docker_target() {
2058 let aliases = vec!["builder".to_owned()];
2059 let mut input = b"y\n1\ndocker\n\n\n".as_slice();
2061 let mut output = Vec::new();
2062
2063 let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new())
2064 .unwrap()
2065 .unwrap();
2066 assert_eq!(
2067 ssh.kind,
2068 SshTargetKind::Docker {
2069 image: DEFAULT_IMAGE.into()
2070 }
2071 );
2072 let config = build_config_with_runtime(&[], None, None, None, Some(&ssh));
2073 let TargetTemplate::SshDocker { ssh, container } = &config.targets["builder"] else {
2074 panic!("setup must write an ssh-docker target");
2075 };
2076 assert_eq!(ssh.host, "builder");
2077 assert_eq!(container.image, DEFAULT_IMAGE);
2078 config.validate().unwrap();
2079 }
2080
2081 #[test]
2082 fn the_ssh_step_rejects_an_unknown_runtime_before_asking_for_an_image() {
2083 let aliases = vec!["builder".to_owned()];
2084 let mut input = b"y\n1\ncontainerd\ndocker\n\n\n".as_slice();
2085 let mut output = Vec::new();
2086
2087 let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new())
2088 .unwrap()
2089 .unwrap();
2090
2091 assert!(matches!(ssh.kind, SshTargetKind::Docker { .. }));
2092 let output = String::from_utf8(output).unwrap();
2093 assert!(output.contains("Runtime must be"), "{output}");
2094 assert_eq!(output.matches("Container image").count(), 1);
2095 }
2096
2097 #[test]
2098 fn accepting_the_ssh_step_writes_an_ssh_podman_target_with_the_default_image() {
2099 let aliases = vec!["builder".to_owned(), "bastion".to_owned()];
2100 let mut input = b"y\n1\n\n\n\n".as_slice();
2102 let mut output = Vec::new();
2103
2104 let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new())
2105 .unwrap()
2106 .unwrap();
2107
2108 assert_eq!(
2109 ssh,
2110 SshTargetInput {
2111 name: "builder".into(),
2112 host: "builder".into(),
2113 kind: SshTargetKind::Podman {
2114 image: DEFAULT_IMAGE.into()
2115 },
2116 }
2117 );
2118 let config = build_config_with_runtime(&[], None, None, None, Some(&ssh));
2119 let TargetTemplate::SshPodman { ssh, container, .. } = &config.targets["builder"] else {
2120 panic!("setup must write an ssh-podman target");
2121 };
2122 assert_eq!(ssh.host, "builder");
2123 assert_eq!(ssh.user, None);
2124 assert_eq!(ssh.identity_file, None);
2125 assert_eq!(container.image, DEFAULT_IMAGE);
2126 config.validate().unwrap();
2127 }
2128
2129 #[test]
2130 fn accepting_the_ssh_step_writes_an_ssh_bare_target_under_a_chosen_name() {
2131 let aliases = vec!["builder".to_owned()];
2132 let mut input = b"y\nother.example.com\nn\n\nremote\n".as_slice();
2134 let mut output = Vec::new();
2135
2136 let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new())
2137 .unwrap()
2138 .unwrap();
2139
2140 assert_eq!(
2141 ssh,
2142 SshTargetInput {
2143 name: "remote".into(),
2144 host: "other.example.com".into(),
2145 kind: SshTargetKind::Bare {
2146 permissions: PermissionMode::Guardian,
2147 },
2148 }
2149 );
2150 let config = build_config_with_runtime(&[], None, None, None, Some(&ssh));
2151 let TargetTemplate::SshBare {
2152 ssh, permissions, ..
2153 } = &config.targets["remote"]
2154 else {
2155 panic!("setup must write an ssh-bare target");
2156 };
2157 assert_eq!(ssh.host, "other.example.com");
2158 assert_eq!(*permissions, PermissionMode::Guardian);
2159 config.validate().unwrap();
2160 }
2161
2162 #[test]
2163 fn the_ssh_step_reasks_until_the_name_is_a_free_and_valid_target_id() {
2164 let aliases = vec!["builder".to_owned()];
2165 let configured = build_config_with_runtime(
2166 &[],
2167 None,
2168 Some((RuntimeKind::Podman, DEFAULT_IMAGE)),
2169 None,
2170 None,
2171 )
2172 .targets;
2173 let mut input = b"y\n1\nn\n\npodman\nbuild host\nbuilder\n".as_slice();
2176 let mut output = Vec::new();
2177
2178 let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &configured)
2179 .unwrap()
2180 .unwrap();
2181
2182 assert_eq!(ssh.name, "builder");
2183 let output = String::from_utf8(output).unwrap();
2184 assert!(
2185 output.contains("Target podman is already configured"),
2186 "{output}"
2187 );
2188 assert!(output.contains("invalid target id"), "{output}");
2189 }
2190
2191 #[test]
2192 fn the_ssh_step_stops_asking_for_a_name_once_the_input_ends() {
2193 let aliases = vec!["podman".to_owned()];
2194 let configured = build_config_with_runtime(
2195 &[],
2196 None,
2197 Some((RuntimeKind::Podman, DEFAULT_IMAGE)),
2198 None,
2199 None,
2200 )
2201 .targets;
2202 let mut input = b"y\n1\nn\n\n".as_slice();
2205 let mut output = Vec::new();
2206
2207 assert_eq!(
2208 prompt_ssh_target(&mut input, &mut output, &aliases, &configured).unwrap(),
2209 None
2210 );
2211 let output = String::from_utf8(output).unwrap();
2212 assert!(output.contains("Input ended; skipping"), "{output}");
2213 }
2214
2215 #[test]
2216 fn an_ssh_target_never_replaces_a_target_configured_earlier() {
2217 let ssh = SshTargetInput {
2218 name: "podman".into(),
2219 host: "builder".into(),
2220 kind: SshTargetKind::Bare {
2221 permissions: PermissionMode::Guardian,
2222 },
2223 };
2224
2225 let config = build_config_with_runtime(
2226 &[],
2227 None,
2228 Some((RuntimeKind::Podman, DEFAULT_IMAGE)),
2229 None,
2230 Some(&ssh),
2231 );
2232
2233 assert!(matches!(
2234 config.targets["podman"],
2235 TargetTemplate::LocalPodman { .. }
2236 ));
2237 assert!(matches!(
2238 config.targets["podman-2"],
2239 TargetTemplate::SshBare { .. }
2240 ));
2241 config.validate().unwrap();
2242 }
2243
2244 #[test]
2245 fn the_github_origin_is_discovered_through_the_shared_executor() {
2246 let executor = RuntimeProbeExecutor::new([ok(b"git@github.com:BrokkAi/hel.git\n")]);
2247
2248 let repository = discover_github_repository(&executor, Path::new("/work/hel")).unwrap();
2249
2250 assert_eq!(repository.source(), "BrokkAi/hel");
2251 let commands = executor.commands.borrow();
2252 assert_eq!(commands[0].program, "git");
2253 assert_eq!(
2254 commands[0].args,
2255 ["-C", "/work/hel", "remote", "get-url", "origin"]
2256 );
2257 }
2258
2259 #[test]
2260 fn no_github_origin_is_reported_when_the_probe_fails() {
2261 let failing = RuntimeProbeExecutor::new([failed(b"not a git repository")]);
2262 assert_eq!(
2263 discover_github_repository(&failing, Path::new("/work/plain")),
2264 None
2265 );
2266
2267 let missing = RuntimeProbeExecutor::new([]);
2268 assert_eq!(
2269 discover_github_repository(&missing, Path::new("/work/plain")),
2270 None
2271 );
2272 }
2273
2274 #[test]
2275 fn declining_the_aws_step_writes_no_aws_target() {
2276 let account = AwsAccount {
2277 account: "123456789012".into(),
2278 arn: "arn:aws:iam::123456789012:user/dev".into(),
2279 region: None,
2280 };
2281 let mut input = b"\n".as_slice();
2282 let mut output = Vec::new();
2283
2284 assert_eq!(
2285 prompt_aws_target(&mut input, &mut output, Some(&account)).unwrap(),
2286 None
2287 );
2288 let config = build_config_with_runtime(&[], None, None, None, None);
2289 assert!(!config.targets.contains_key(AWS_TARGET_ID));
2290 }
2291
2292 #[test]
2293 fn smoke_test_removes_the_container_after_a_failed_command() {
2294 let executor = FakeExecutor {
2295 commands: RefCell::new(vec![]),
2296 statuses: vec![0, 1, 0],
2297 };
2298 let mut output = Vec::new();
2299
2300 assert!(
2301 run_smoke_test(
2302 &mut output,
2303 &smoke_target(RuntimeKind::Podman, "ubuntu:24.04"),
2304 &executor
2305 )
2306 .is_err()
2307 );
2308 let commands = executor.commands.borrow();
2309 assert_eq!(commands.len(), 3);
2310 assert_eq!(commands[2].args[0], "rm");
2311 }
2312
2313 #[test]
2314 fn docker_smoke_test_exercises_the_managed_overlay_attachment_path() {
2315 let executor = FakeExecutor::succeeds();
2316 let mut output = Vec::new();
2317
2318 run_smoke_test(
2319 &mut output,
2320 &smoke_target(RuntimeKind::Docker, "ubuntu:24.04"),
2321 &executor,
2322 )
2323 .unwrap();
2324
2325 let commands = executor.commands.borrow();
2326 assert_eq!(commands.len(), 3);
2327 assert_eq!(commands[0].program, "sh");
2328 assert!(commands[0].args[1].contains("docker volume create"));
2329 assert!(commands[0].args[1].contains("type=overlay"));
2330 assert_eq!(commands[1].program, "docker");
2331 assert_eq!(commands[1].args[0], "exec");
2332 assert_eq!(commands[2].program, "sh");
2333 assert!(commands[2].args[1].contains("docker volume rm --force"));
2334 assert!(
2335 String::from_utf8(output)
2336 .unwrap()
2337 .contains("writable OverlayFS attachment")
2338 );
2339 }
2340
2341 #[test]
2342 fn setup_preserves_working_configuration_and_discovers_new_installations() {
2343 let directory = tempfile::tempdir().unwrap();
2344 let path = directory.path().join("config.toml");
2345 let home = DiscoveredHome {
2346 kind: HarnessKind::Codex,
2347 path: directory.path().join("codex"),
2348 authenticated: true,
2349 };
2350 let repository = GithubRepository {
2351 owner: "BrokkAi".into(),
2352 repository: "muse-acp".into(),
2353 };
2354 let mut original = build_config_with_runtimes(
2355 std::slice::from_ref(&home),
2356 Some(&repository),
2357 &[],
2358 None,
2359 None,
2360 );
2361 original.profiles.get_mut("codex").unwrap().enabled = false;
2362 original
2363 .profiles
2364 .get_mut("codex")
2365 .unwrap()
2366 .environment
2367 .insert("KEEP".into(), "custom".into());
2368 original.phone.enabled = false;
2369 original.startup.enabled = false;
2370 original.save_to(&path).unwrap();
2371 let discovery = SetupDiscovery {
2372 homes: vec![
2373 home,
2374 DiscoveredHome {
2375 kind: HarnessKind::Muse,
2376 path: directory.path().join("muse"),
2377 authenticated: true,
2378 },
2379 ],
2380 repository: Some(GithubRepository {
2381 owner: "BrokkAi".into(),
2382 repository: "mjolnir".into(),
2383 }),
2384 ..discovery_without_runtimes()
2385 };
2386 let executor = FakeExecutor::succeeds();
2387 for _ in 0..2 {
2388 let mut output = Vec::new();
2389 run_setup_dialog_with(
2390 &mut b"y\n".as_slice(),
2391 &mut output,
2392 &path,
2393 &discovery,
2394 &executor,
2395 &executor,
2396 )
2397 .unwrap();
2398 let saved = HelConfig::load_from(&path).unwrap();
2399 assert_eq!(saved.profiles["codex"], original.profiles["codex"]);
2400 assert_eq!(
2401 saved.bundles["current-repository"],
2402 original.bundles["current-repository"]
2403 );
2404 assert_eq!(saved.targets, original.targets);
2405 assert_eq!(saved.phone, original.phone);
2406 assert_eq!(saved.startup, original.startup);
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 HelConfig::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 HelConfig::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 = HelConfig::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 = HelConfig::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 = HelConfig::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}