Skip to main content

mj_controller/
hel_setup.rs

1//! Plain-stdio first-run configuration for Hel.
2
3use 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};
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, validate_id,
19};
20use hel::hel_targets::{
21    CancellableProcessExecutor, CommandExecutor, CommandSpec,
22    ContainerTemplate as RuntimeContainerTemplate, ProcessExecutor,
23    TargetTemplate as RuntimeTargetTemplate, run_setup_smoke_test,
24};
25
26/// AWS credential detection must never stall an interactive first run, so the
27/// probe commands share a bounded deadline.
28const AWS_PROBE_TIMEOUT: Duration = Duration::from_secs(8);
29
30/// The user every Hel launch template image boots with; see
31/// scripts/update-runson-launch-template.sh.
32const DEFAULT_AWS_SSH_USER: &str = "ubuntu";
33const AWS_TARGET_ID: &str = "aws";
34
35// Published from containers/Containerfile.agent-dev by
36// .github/workflows/publish-agent-dev-image.yml. It already carries Node, Rust,
37// Git, gh, and the pinned ACP bridges, so a first session does not have to
38// install them.
39pub const DEFAULT_IMAGE: &str = "ghcr.io/brokkai/mjolnir/agent-dev:latest";
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct DiscoveredHome {
43    pub kind: HarnessKind,
44    pub path: PathBuf,
45    pub authenticated: bool,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct GithubRepository {
50    pub owner: String,
51    pub repository: String,
52}
53
54impl GithubRepository {
55    fn source(&self) -> String {
56        format!("{}/{}", self.owner, self.repository)
57    }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum RuntimeKind {
62    Podman,
63    Docker,
64    AppleContainer,
65}
66
67impl RuntimeKind {
68    fn id(self) -> &'static str {
69        match self {
70            Self::Podman => "podman",
71            Self::Docker => "docker",
72            Self::AppleContainer => "apple-container",
73        }
74    }
75
76    fn label(self) -> &'static str {
77        match self {
78            Self::Podman => "Podman",
79            Self::Docker => "Docker",
80            Self::AppleContainer => "Apple container",
81        }
82    }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct RuntimeProbe {
87    pub kind: RuntimeKind,
88    pub usable: bool,
89    pub detail: String,
90    /// The fix `mj doctor` would print for this runtime, carried through so
91    /// setup never invents its own remediation wording.
92    pub remediation: Option<String>,
93}
94
95/// An AWS identity that `aws sts get-caller-identity` confirmed.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct AwsAccount {
98    pub account: String,
99    pub arn: String,
100    /// The CLI's configured default region, when it has one.
101    pub region: Option<String>,
102}
103
104/// The answers that become a `[targets.aws]` entry.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct AwsTargetInput {
107    pub launch_template: String,
108    pub region: String,
109    pub ssh_user: String,
110    pub identity_file: Option<PathBuf>,
111}
112
113/// Which kind of SSH target the user chose in the SSH step.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub enum SshTargetKind {
116    Bare { permissions: PermissionMode },
117    Podman { image: String },
118    Docker { image: String },
119}
120
121/// The answers that become a `[targets.<name>]` SSH entry.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct SshTargetInput {
124    pub name: String,
125    pub host: String,
126    pub kind: SshTargetKind,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct SetupDiscovery {
131    pub homes: Vec<DiscoveredHome>,
132    pub repository: Option<GithubRepository>,
133    pub runtimes: Vec<RuntimeProbe>,
134    /// `None` when this host has no working AWS CLI credentials, in which case
135    /// setup never offers an AWS target.
136    pub aws: Option<AwsAccount>,
137    /// Concrete `Host` aliases read from `~/.ssh/config`; empty when the file
138    /// is absent or only defines wildcard blocks.
139    pub ssh_hosts: Vec<String>,
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum SetupOutcome {
144    Written,
145    Cancelled,
146}
147
148/// Give an unconfigured terminal installation a local Codex profile and target
149/// without making remote/container setup a prerequisite for explicit session
150/// creation. This only writes configuration; it never creates a session.
151pub fn initialize_local_startup_config(config_path: &Path) -> Result<()> {
152    #[cfg(unix)]
153    {
154        let config = HelConfig::load_from(config_path)?;
155        if config.is_unconfigured() && config.newer_config_version.is_none() {
156            let kind = HarnessKind::Codex;
157            let home = std::env::var_os(kind.home_env())
158                .map(|value| kind.home_from_environment(value))
159                .or_else(|| dirs::home_dir().map(|home| home.join(kind.default_home_leaf())))
160                .context("locate Codex home for the default local profile")?;
161            let home = std::path::absolute(home).context("resolve Codex profile home")?;
162            HelConfig::update_to(config_path, |fresh| {
163                if fresh.is_unconfigured() {
164                    configure_local_startup(fresh, home);
165                }
166                Ok(())
167            })?;
168        }
169    }
170    // Local bare targets are unsupported on Windows; retain explicit setup.
171    #[cfg(not(unix))]
172    let _ = config_path;
173    Ok(())
174}
175
176#[cfg(unix)]
177fn configure_local_startup(config: &mut HelConfig, codex_home: PathBuf) {
178    config.profiles.insert(
179        "codex".into(),
180        HarnessProfile {
181            kind: HarnessKind::Codex,
182            home: codex_home,
183            environment: BTreeMap::new(),
184            context_window_bytes: None,
185        },
186    );
187    config
188        .targets
189        .insert("localhost".into(), TargetTemplate::LocalBare);
190}
191
192/// Run the setup dialog using the user's normal standard input and output.
193pub fn run_setup_dialog(config_path: &Path) -> Result<SetupOutcome> {
194    // Prerequisite probes run under doctor's per-probe deadline, so a wedged
195    // container socket cannot stall the first run; only the smoke test, which
196    // may pull an image, is allowed to take as long as it needs.
197    let probes = probe_executor();
198    let discovery = discover_current(&probes);
199    let stdout = io::stdout();
200    let mut input = ReadlinePrompter::default();
201    run_setup_dialog_inner(
202        &mut input,
203        &mut stdout.lock(),
204        config_path,
205        &discovery,
206        &ProcessExecutor,
207        &probes,
208    )
209}
210
211pub fn discover_current(executor: &impl CommandExecutor) -> SetupDiscovery {
212    let home = dirs::home_dir();
213    let overrides = HarnessKind::ALL.into_iter().filter_map(|kind| {
214        std::env::var_os(kind.home_env()).map(|path| (kind, kind.home_from_environment(path)))
215    });
216    let homes = discover_harness_homes_with_executor(home.as_deref(), overrides, executor);
217    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
218
219    SetupDiscovery {
220        homes,
221        repository: discover_github_repository(executor, &cwd),
222        runtimes: probe_local_runtimes(executor, cfg!(target_os = "macos")),
223        aws: detect_aws(&CancellableProcessExecutor::with_timeout(AWS_PROBE_TIMEOUT)),
224        ssh_hosts: discover_ssh_hosts(home.as_deref()),
225    }
226}
227
228/// Read the concrete `Host` aliases from `~/.ssh/config`.
229///
230/// This is a pure read: setup never runs `ssh` while discovering. `Include`
231/// directives are deliberately not followed, because resolving them correctly
232/// means reimplementing OpenSSH's glob and relative-path rules; aliases that
233/// live in an included file simply are not offered, and the user can still
234/// type a host by hand.
235pub fn discover_ssh_hosts(home: Option<&Path>) -> Vec<String> {
236    let Some(home) = home else {
237        return Vec::new();
238    };
239    let Ok(contents) = std::fs::read_to_string(home.join(".ssh").join("config")) else {
240        return Vec::new();
241    };
242    ssh_config_aliases(&contents)
243}
244
245/// Extract the usable `Host` aliases from SSH config text.
246///
247/// Pattern entries (`*`, `?`, `!`) are skipped: they configure other hosts
248/// rather than naming one Hel could connect to.
249pub fn ssh_config_aliases(contents: &str) -> Vec<String> {
250    let mut aliases: Vec<String> = Vec::new();
251    for line in contents.lines() {
252        let line = line.trim();
253        if line.is_empty() || line.starts_with('#') {
254            continue;
255        }
256        let Some((keyword, rest)) = line.split_once(char::is_whitespace) else {
257            continue;
258        };
259        if !keyword.eq_ignore_ascii_case("host") {
260            continue;
261        }
262        for alias in rest.split_whitespace() {
263            let alias = alias.trim_matches('"');
264            if alias.is_empty() || alias.contains(['*', '?', '!']) {
265                continue;
266            }
267            if !aliases.iter().any(|existing| existing == alias) {
268                aliases.push(alias.to_owned());
269            }
270        }
271    }
272    aliases
273}
274
275pub fn discover_harness_homes(
276    home: Option<&Path>,
277    overrides: impl IntoIterator<Item = (HarnessKind, PathBuf)>,
278) -> Vec<DiscoveredHome> {
279    discover_harness_homes_with_executor(home, overrides, &probe_executor())
280}
281
282pub(crate) fn discover_harness_homes_with_executor(
283    home: Option<&Path>,
284    overrides: impl IntoIterator<Item = (HarnessKind, PathBuf)>,
285    executor: &impl CommandExecutor,
286) -> Vec<DiscoveredHome> {
287    let mut candidates = Vec::new();
288    if let Some(home) = home {
289        candidates.extend(
290            HarnessKind::ALL
291                .into_iter()
292                .map(|kind| (kind, home.join(kind.default_home_leaf()), true)),
293        );
294    }
295    candidates.extend(
296        overrides
297            .into_iter()
298            .map(|(kind, path)| (kind, path, false)),
299    );
300
301    let mut seen = BTreeSet::new();
302    candidates
303        .into_iter()
304        .filter(|(kind, path, _)| seen.insert((*kind, path.clone())) && path.is_dir())
305        .map(|(kind, path, is_default_home)| DiscoveredHome {
306            authenticated: harness_is_authenticated_with(kind, &path, is_default_home, executor),
307            kind,
308            path,
309        })
310        .collect()
311}
312
313pub fn harness_is_authenticated(kind: HarnessKind, home: &Path) -> bool {
314    harness_is_authenticated_with_executor(kind, home, &probe_executor())
315}
316
317pub(crate) fn harness_is_authenticated_with_executor(
318    kind: HarnessKind,
319    home: &Path,
320    executor: &impl CommandExecutor,
321) -> bool {
322    let is_default_home =
323        dirs::home_dir().is_some_and(|user_home| home == user_home.join(kind.default_home_leaf()));
324    harness_is_authenticated_with(kind, home, is_default_home, executor)
325}
326
327fn harness_is_authenticated_with(
328    kind: HarnessKind,
329    home: &Path,
330    is_default_home: bool,
331    executor: &impl CommandExecutor,
332) -> bool {
333    if harness_authentication_marker(kind, home).is_file()
334        || (kind == HarnessKind::Kimi && home.join("credentials").is_file())
335    {
336        return true;
337    }
338    if kind != HarnessKind::Claude {
339        return false;
340    }
341    if is_default_home && claude_keychain_reports_authenticated(executor) {
342        return true;
343    }
344    if !is_default_home && claude_cli_reports_authenticated(home, executor) {
345        return true;
346    }
347    false
348}
349
350/// Ask Claude Code about a scoped profile. Setting `CLAUDE_CONFIG_DIR` for the
351/// default home changes Claude's profile selection, so the default macOS
352/// profile is checked directly in the Keychain instead.
353fn claude_cli_reports_authenticated(home: &Path, executor: &impl CommandExecutor) -> bool {
354    let mut command = CommandSpec::new("claude", ["auth", "status", "--json"])
355        .purpose("check Claude Code authentication");
356    command.env.insert(
357        HarnessKind::Claude.home_env().to_owned(),
358        home.to_string_lossy().into_owned(),
359    );
360    let Ok(output) = executor.execute(&command) else {
361        return false;
362    };
363    if output.status != 0 {
364        return false;
365    }
366    serde_json::from_slice::<serde_json::Value>(&output.stdout)
367        .ok()
368        .and_then(|status| status.get("loggedIn").and_then(serde_json::Value::as_bool))
369        == Some(true)
370}
371
372/// Mjolnir and Claude Code use this service for the default macOS profile.
373/// `security` is already authorized for the item, so this does not raise a
374/// Keychain prompt; the shared executor still bounds a wedged lookup.
375#[cfg(target_os = "macos")]
376fn claude_keychain_reports_authenticated(executor: &impl CommandExecutor) -> bool {
377    let command = CommandSpec::new(
378        "security",
379        [
380            "find-generic-password",
381            "-s",
382            "Claude Code-credentials",
383            "-w",
384        ],
385    )
386    .purpose("check Claude Code authentication in the macOS Keychain");
387    let Ok(output) = executor.execute(&command) else {
388        return false;
389    };
390    output.status == 0 && claude_credentials_contain_login(&output.stdout)
391}
392
393#[cfg(not(target_os = "macos"))]
394fn claude_keychain_reports_authenticated(_executor: &impl CommandExecutor) -> bool {
395    false
396}
397
398#[cfg(any(target_os = "macos", test))]
399fn claude_credentials_contain_login(credentials: &[u8]) -> bool {
400    let Ok(document) = serde_json::from_slice::<serde_json::Value>(credentials) else {
401        return false;
402    };
403    [
404        "/claudeAiOauth/accessToken",
405        "/claudeAiOauth/refreshToken",
406        "/oauth/accessToken",
407        "/apiKey",
408    ]
409    .into_iter()
410    .any(|pointer| {
411        document
412            .pointer(pointer)
413            .and_then(serde_json::Value::as_str)
414            .is_some_and(|value| !value.trim().is_empty())
415    })
416}
417
418pub fn github_repository_from_origin(origin: &str) -> Option<GithubRepository> {
419    let origin = origin.trim();
420    let path = origin
421        .strip_prefix("https://github.com/")
422        .or_else(|| origin.strip_prefix("http://github.com/"))
423        .or_else(|| origin.strip_prefix("git@github.com:"))
424        .or_else(|| origin.strip_prefix("ssh://git@github.com/"))
425        // Config accepts owner/repository shorthand, and import uses the same
426        // parser to compare that configured source with `git remote` output.
427        .unwrap_or(origin);
428    let path = path.trim_end_matches(".git");
429    let mut parts = path.split('/');
430    let owner = parts.next()?;
431    let repository = parts.next()?;
432    if owner.is_empty()
433        || repository.is_empty()
434        || parts.next().is_some()
435        || owner.chars().any(char::is_whitespace)
436        || repository.chars().any(char::is_whitespace)
437    {
438        return None;
439    }
440    Some(GithubRepository {
441        owner: owner.to_owned(),
442        repository: repository.to_owned(),
443    })
444}
445
446/// Read the current directory's GitHub origin, through the same executor every
447/// other discovery probe uses so it is bounded and can be faked in tests.
448///
449/// `git -C` selects the directory instead of a working-directory field on the
450/// command, which no executor carries.
451fn discover_github_repository(
452    executor: &impl CommandExecutor,
453    cwd: &Path,
454) -> Option<GithubRepository> {
455    let command = CommandSpec::new(
456        "git",
457        [
458            "-C".to_owned(),
459            cwd.to_string_lossy().into_owned(),
460            "remote".to_owned(),
461            "get-url".to_owned(),
462            "origin".to_owned(),
463        ],
464    )
465    .purpose("detect the current repository's GitHub origin");
466    let output = executor.execute(&command).ok()?;
467    if output.status != 0 {
468        return None;
469    }
470    github_repository_from_origin(&String::from_utf8_lossy(&output.stdout))
471}
472
473/// Probe the container runtimes setup can configure, reusing the doctor checks
474/// so an unavailable runtime carries doctor's detail and remediation.
475pub fn probe_local_runtimes(executor: &impl CommandExecutor, is_macos: bool) -> Vec<RuntimeProbe> {
476    let mut probes = vec![
477        runtime_probe_from_check(RuntimeKind::Podman, local_podman_runtime_check(executor)),
478        runtime_probe_from_check(RuntimeKind::Docker, local_docker_runtime_check(executor)),
479    ];
480    if is_macos {
481        probes.push(runtime_probe_from_check(
482            RuntimeKind::AppleContainer,
483            apple_container_daemon_check(executor),
484        ));
485    }
486    probes
487}
488
489fn runtime_probe_from_check(
490    kind: RuntimeKind,
491    check: crate::hel_doctor::DoctorCheck,
492) -> RuntimeProbe {
493    RuntimeProbe {
494        kind,
495        usable: check.status == CheckStatus::Ready,
496        detail: check.detail,
497        remediation: check.remediation,
498    }
499}
500
501/// Detect a usable AWS CLI identity on this host.
502///
503/// Returns `None` whenever the CLI is missing or its credentials do not work,
504/// so setup can skip the AWS step instead of prompting for a target that could
505/// never launch.
506pub fn detect_aws(executor: &impl CommandExecutor) -> Option<AwsAccount> {
507    let identity = CommandSpec::new("aws", ["sts", "get-caller-identity", "--output", "json"])
508        .purpose("detect AWS credentials");
509    let output = executor.execute(&identity).ok()?;
510    if output.status != 0 {
511        return None;
512    }
513    let identity: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
514    let account = identity.get("Account")?.as_str()?.to_owned();
515    let arn = identity.get("Arn")?.as_str()?.to_owned();
516    Some(AwsAccount {
517        account,
518        arn,
519        region: configured_aws_region(executor),
520    })
521}
522
523fn configured_aws_region(executor: &impl CommandExecutor) -> Option<String> {
524    let command = CommandSpec::new("aws", ["configure", "get", "region"])
525        .purpose("read the default AWS region");
526    let output = executor.execute(&command).ok()?;
527    if output.status != 0 {
528        return None;
529    }
530    let region = String::from_utf8_lossy(&output.stdout).trim().to_owned();
531    (!region.is_empty()).then_some(region)
532}
533
534pub fn build_config(
535    homes: &[DiscoveredHome],
536    repository: Option<&GithubRepository>,
537    runtime: RuntimeKind,
538    image: &str,
539) -> HelConfig {
540    build_config_with_runtime(homes, repository, Some((runtime, image)), None, None)
541}
542
543fn build_config_with_runtime(
544    homes: &[DiscoveredHome],
545    repository: Option<&GithubRepository>,
546    runtime: Option<(RuntimeKind, &str)>,
547    aws: Option<&AwsTargetInput>,
548    ssh: Option<&SshTargetInput>,
549) -> HelConfig {
550    build_config_with_runtimes(
551        homes,
552        repository,
553        &runtime.into_iter().collect::<Vec<_>>(),
554        aws,
555        ssh,
556    )
557}
558
559fn build_config_with_runtimes(
560    homes: &[DiscoveredHome],
561    repository: Option<&GithubRepository>,
562    runtimes: &[(RuntimeKind, &str)],
563    aws: Option<&AwsTargetInput>,
564    ssh: Option<&SshTargetInput>,
565) -> HelConfig {
566    let mut config = HelConfig::default();
567    for home in homes {
568        let id = unique_id(&config.profiles, home.kind.id());
569        config.profiles.insert(
570            id,
571            HarnessProfile {
572                kind: home.kind,
573                home: home.path.clone(),
574                environment: BTreeMap::new(),
575                context_window_bytes: None,
576            },
577        );
578    }
579
580    if let Some(repository) = repository {
581        let repository_id = config_id(&repository.repository);
582        config.bundles.insert(
583            "current-repository".to_owned(),
584            ProjectBundle {
585                primary_repo: repository_id.clone(),
586                repositories: vec![ProjectRepository {
587                    id: repository_id.clone(),
588                    github: Some(repository.source()),
589                    local: None,
590                    destination: PathBuf::from(repository_id),
591                    git_ref: None,
592                }],
593            },
594        );
595    }
596
597    #[cfg(unix)]
598    config
599        .targets
600        .insert("localhost".to_owned(), TargetTemplate::LocalBare);
601    for (runtime, image) in runtimes {
602        let (target_id, target) = local_runtime_target(*runtime, image);
603        config.targets.insert(target_id.to_owned(), target);
604    }
605    if let Some(aws) = aws {
606        config.targets.insert(
607            AWS_TARGET_ID.to_owned(),
608            TargetTemplate::AwsEc2 {
609                aws_profile: None,
610                region: aws.region.clone(),
611                launch_template: aws.launch_template.clone(),
612                launch_template_version: None,
613                ssh_user: aws.ssh_user.clone(),
614                address_source: AwsAddressSource::default(),
615                identity_file: aws.identity_file.clone(),
616                ssh_args: vec![],
617            },
618        );
619    }
620    if let Some(ssh) = ssh {
621        // Leave user and identity file unset: the SSH config alias already
622        // carries whatever the user configured for this host.
623        let connection = SshConnection {
624            host: ssh.host.clone(),
625            user: None,
626            identity_file: None,
627            extra_args: vec![],
628        };
629        let target = match &ssh.kind {
630            SshTargetKind::Bare { permissions } => TargetTemplate::SshBare {
631                ssh: connection,
632                permissions: *permissions,
633                workspace_prefix: default_ssh_workspace_prefix(),
634            },
635            SshTargetKind::Podman { image } => TargetTemplate::SshPodman {
636                ssh: connection,
637                container: ContainerTemplate {
638                    image: image.clone(),
639                    pull_policy: Default::default(),
640                    platform: None,
641                    cpus: None,
642                    memory: None,
643                    environment: BTreeMap::new(),
644                    workspace_storage: Default::default(),
645                },
646            },
647            SshTargetKind::Docker { image } => TargetTemplate::SshDocker {
648                ssh: connection,
649                container: ContainerTemplate {
650                    image: image.clone(),
651                    pull_policy: Default::default(),
652                    platform: None,
653                    cpus: None,
654                    memory: None,
655                    environment: BTreeMap::new(),
656                    workspace_storage: Default::default(),
657                },
658            },
659        };
660        // The dialog already refuses a name that collides, so this only guards
661        // a caller that builds a config without asking: a chosen SSH name must
662        // never silently replace a target configured moments earlier.
663        config
664            .targets
665            .insert(unique_id(&config.targets, &ssh.name), target);
666    }
667    config
668}
669
670/// The shared setup/startup template for a locally available container engine.
671pub fn local_runtime_target(runtime: RuntimeKind, image: &str) -> (&'static str, TargetTemplate) {
672    let container = ContainerTemplate {
673        image: image.trim().to_owned(),
674        pull_policy: Default::default(),
675        platform: None,
676        cpus: None,
677        memory: None,
678        environment: BTreeMap::new(),
679        workspace_storage: Default::default(),
680    };
681    match runtime {
682        RuntimeKind::Podman => ("podman", TargetTemplate::LocalPodman { container }),
683        RuntimeKind::Docker => ("docker", TargetTemplate::LocalDocker { container }),
684        RuntimeKind::AppleContainer => (
685            "apple-container",
686            TargetTemplate::AppleContainer { container },
687        ),
688    }
689}
690
691/// The same default `serde` applies to a hand-written `ssh-bare` target.
692fn default_ssh_workspace_prefix() -> PathBuf {
693    PathBuf::from(".local/share/hel/workspaces")
694}
695
696/// The first free id at or after `base_id`, so building a config never drops an
697/// entry by inserting over one that is already there.
698fn unique_id<T>(entries: &BTreeMap<String, T>, base_id: &str) -> String {
699    if !entries.contains_key(base_id) {
700        return base_id.to_owned();
701    }
702    let mut number = 2;
703    loop {
704        let candidate = format!("{base_id}-{number}");
705        if !entries.contains_key(&candidate) {
706            return candidate;
707        }
708        number += 1;
709    }
710}
711
712fn config_id(value: &str) -> String {
713    let mut id = value
714        .chars()
715        .filter(|character| {
716            character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
717        })
718        .take(64)
719        .collect::<String>();
720    if id.is_empty() || matches!(id.as_str(), "." | "..") {
721        id = "repository".to_owned();
722    }
723    id
724}
725
726/// Ask the setup questions, write the configuration, and report on it.
727///
728/// The smoke test and the closing doctor report run through different
729/// executors on purpose: a smoke test may pull a multi-gigabyte image and must
730/// not be given a deadline, while every prerequisite probe must answer quickly
731/// or be reported as a fixable check.
732pub fn run_setup_dialog_with(
733    input: &mut impl BufRead,
734    output: &mut impl Write,
735    config_path: &Path,
736    discovery: &SetupDiscovery,
737    smoke_executor: &impl CommandExecutor,
738    probe_executor: &impl CommandExecutor,
739) -> Result<SetupOutcome> {
740    run_setup_dialog_inner(
741        input,
742        output,
743        config_path,
744        discovery,
745        smoke_executor,
746        probe_executor,
747    )
748}
749
750fn run_setup_dialog_inner(
751    input: &mut impl SetupPrompter,
752    output: &mut impl Write,
753    config_path: &Path,
754    discovery: &SetupDiscovery,
755    smoke_executor: &impl CommandExecutor,
756    probe_executor: &impl CommandExecutor,
757) -> Result<SetupOutcome> {
758    writeln!(output, "Welcome to Mjolnir setup.")?;
759    writeln!(output)?;
760    write_discovered_homes(output, &discovery.homes)?;
761    write_repository(output, discovery.repository.as_ref())?;
762    write_runtimes(output, &discovery.runtimes)?;
763
764    let runtimes = if discovery.runtimes.iter().any(|runtime| runtime.usable) {
765        let image = prompt(
766            input,
767            output,
768            &format!("Container image [{DEFAULT_IMAGE}]: "),
769        )?;
770        let image = if image.is_empty() {
771            DEFAULT_IMAGE.to_owned()
772        } else {
773            image
774        };
775        discovery
776            .runtimes
777            .iter()
778            .filter(|runtime| runtime.usable)
779            .map(|runtime| (runtime.kind, image.clone()))
780            .collect::<Vec<_>>()
781    } else {
782        writeln!(
783            output,
784            "No usable container runtime found; raw localhost will still be configured."
785        )?;
786        Vec::new()
787    };
788    let aws = prompt_aws_target(input, output, discovery.aws.as_ref())?;
789    let runtime_choices = runtimes
790        .iter()
791        .map(|(runtime, image)| (*runtime, image.as_str()))
792        .collect::<Vec<_>>();
793    // Build what the earlier answers already claimed, so the SSH step can
794    // refuse a target name that would replace one of them.
795    let configured = build_config_with_runtimes(
796        &discovery.homes,
797        discovery.repository.as_ref(),
798        &runtime_choices,
799        aws.as_ref(),
800        None,
801    );
802    let ssh = prompt_ssh_target(input, output, &discovery.ssh_hosts, &configured.targets)?;
803    let config = build_config_with_runtimes(
804        &discovery.homes,
805        discovery.repository.as_ref(),
806        &runtime_choices,
807        aws.as_ref(),
808        ssh.as_ref(),
809    );
810    config.validate()?;
811
812    writeln!(output)?;
813    write_summary(output, config_path, &config, &runtimes)?;
814    let confirmation = prompt(input, output, "Write this configuration? [y/N]: ")?;
815    if !matches!(confirmation.to_ascii_lowercase().as_str(), "y" | "yes") {
816        writeln!(output, "Setup cancelled.")?;
817        return Ok(SetupOutcome::Cancelled);
818    }
819
820    writeln!(output, "Writing {}...", config_path.display())?;
821    config.save_to(config_path)?;
822    // A failed smoke test is a fixable prerequisite, not a reason to abandon
823    // the run: the configuration is already written, and this is exactly when
824    // the closing report's remediations matter most.
825    let smoke_failures = runtimes
826        .iter()
827        .filter_map(|(runtime, image)| {
828            let target = smoke_target(*runtime, image);
829            run_smoke_test(output, &target, smoke_executor)
830                .err()
831                .map(|error| smoke_failure_check(*runtime, image, &error))
832        })
833        .collect();
834    write_doctor_report(output, config_path, probe_executor, smoke_failures)?;
835    writeln!(
836        output,
837        "Advanced users can edit TOML for extra profiles, virtual monorepos, SSH, and AWS."
838    )?;
839    writeln!(output, "Press n to start your first session.")?;
840    Ok(SetupOutcome::Written)
841}
842
843fn write_discovered_homes(output: &mut impl Write, homes: &[DiscoveredHome]) -> Result<()> {
844    writeln!(output, "Harness homes:")?;
845    if homes.is_empty() {
846        writeln!(
847            output,
848            "  No existing Codex, Claude Code, Kimi Code, or Grok Build homes found."
849        )?;
850    }
851    for home in homes {
852        let authentication = if home.authenticated {
853            "authenticated"
854        } else {
855            "not authenticated"
856        };
857        writeln!(
858            output,
859            "  {}: {} ({authentication}){}",
860            home.kind.display_name(),
861            home.path.display(),
862            match home.kind.unsandboxed_guardian_warning() {
863                Some(warning) => format!(" — {warning}"),
864                None => String::new(),
865            }
866        )?;
867    }
868    Ok(())
869}
870
871fn write_repository(output: &mut impl Write, repository: Option<&GithubRepository>) -> Result<()> {
872    match repository {
873        Some(repository) => writeln!(
874            output,
875            "GitHub origin: {} (a one-repository bundle will be created)",
876            repository.source()
877        )?,
878        None => writeln!(
879            output,
880            "GitHub origin: none detected in the current directory."
881        )?,
882    }
883    Ok(())
884}
885
886fn write_runtimes(output: &mut impl Write, runtimes: &[RuntimeProbe]) -> Result<()> {
887    writeln!(output, "Local runtimes:")?;
888    for runtime in runtimes {
889        let state = if runtime.usable {
890            "usable"
891        } else {
892            "unavailable"
893        };
894        if runtime.detail.is_empty() {
895            writeln!(output, "  {}: {state}", runtime.kind.label())?;
896        } else {
897            writeln!(
898                output,
899                "  {}: {state} ({})",
900                runtime.kind.label(),
901                runtime.detail
902            )?;
903        }
904        if let Some(remediation) = &runtime.remediation {
905            writeln!(output, "    remediation: {remediation}")?;
906        }
907    }
908    Ok(())
909}
910
911/// Offer an AWS EC2 target, but only when this host already has working AWS
912/// credentials. Without them the step prints one line and asks nothing.
913fn prompt_aws_target(
914    input: &mut impl SetupPrompter,
915    output: &mut impl Write,
916    account: Option<&AwsAccount>,
917) -> Result<Option<AwsTargetInput>> {
918    let Some(account) = account else {
919        writeln!(
920            output,
921            "AWS: no working `aws` CLI credentials found; skipping the AWS target."
922        )?;
923        return Ok(None);
924    };
925    writeln!(
926        output,
927        "AWS: credentials are valid for account {} ({}).",
928        account.account, account.arn
929    )?;
930    let answer = prompt(input, output, "Add an AWS EC2 target? [y/N]: ")?;
931    if !matches!(answer.to_ascii_lowercase().as_str(), "y" | "yes") {
932        return Ok(None);
933    }
934
935    let launch_template = prompt(input, output, "Launch template name: ")?;
936    if launch_template.is_empty() {
937        writeln!(
938            output,
939            "A launch template name is required; skipping the AWS target."
940        )?;
941        return Ok(None);
942    }
943
944    let region_label = match &account.region {
945        Some(region) => format!("Region [{region}]: "),
946        None => "Region: ".to_owned(),
947    };
948    let region = prompt(input, output, &region_label)?;
949    let region = if region.is_empty() {
950        match &account.region {
951            Some(region) => region.clone(),
952            None => {
953                writeln!(output, "A region is required; skipping the AWS target.")?;
954                return Ok(None);
955            }
956        }
957    } else {
958        region
959    };
960
961    let ssh_user = prompt(
962        input,
963        output,
964        &format!("SSH user [{DEFAULT_AWS_SSH_USER}]: "),
965    )?;
966    let ssh_user = if ssh_user.is_empty() {
967        DEFAULT_AWS_SSH_USER.to_owned()
968    } else {
969        ssh_user
970    };
971    let identity_file = prompt(input, output, "SSH identity file (optional): ")?;
972
973    Ok(Some(AwsTargetInput {
974        launch_template,
975        region,
976        ssh_user,
977        identity_file: (!identity_file.is_empty()).then(|| PathBuf::from(identity_file)),
978    }))
979}
980
981/// Offer an SSH target built from the aliases in `~/.ssh/config`.
982///
983/// With no aliases the step prints one line and asks nothing, the same way the
984/// AWS step reports skipping.
985fn prompt_ssh_target(
986    input: &mut impl SetupPrompter,
987    output: &mut impl Write,
988    aliases: &[String],
989    configured: &BTreeMap<String, TargetTemplate>,
990) -> Result<Option<SshTargetInput>> {
991    if aliases.is_empty() {
992        writeln!(
993            output,
994            "SSH: no host aliases found in ~/.ssh/config; skipping the SSH target."
995        )?;
996        return Ok(None);
997    }
998    writeln!(output, "SSH: hosts found in ~/.ssh/config:")?;
999    for (index, alias) in aliases.iter().enumerate() {
1000        writeln!(output, "  {}) {alias}", index + 1)?;
1001    }
1002    let answer = prompt(input, output, "Add an SSH target? [y/N]: ")?;
1003    if !matches!(answer.to_ascii_lowercase().as_str(), "y" | "yes") {
1004        return Ok(None);
1005    }
1006
1007    let choice = prompt(
1008        input,
1009        output,
1010        &format!("Host number 1-{} or a host name: ", aliases.len()),
1011    )?;
1012    let host = match choice.parse::<usize>() {
1013        Ok(index) if (1..=aliases.len()).contains(&index) => aliases[index - 1].clone(),
1014        _ if !choice.is_empty() => choice,
1015        _ => {
1016            writeln!(output, "A host is required; skipping the SSH target.")?;
1017            return Ok(None);
1018        }
1019    };
1020
1021    let kind = loop {
1022        let runtime = prompt(
1023            input,
1024            output,
1025            "Container runtime on that host, podman, docker, or bare [podman]: ",
1026        )?;
1027        let runtime = runtime.to_ascii_lowercase();
1028        if matches!(runtime.as_str(), "n" | "no" | "bare") {
1029            let permissions = loop {
1030                let mode = prompt(
1031                    input,
1032                    output,
1033                    "Raw-host permissions, guardian or yolo [guardian]: ",
1034                )?;
1035                match mode.to_ascii_lowercase().as_str() {
1036                    "" | "guardian" => break PermissionMode::Guardian,
1037                    "yolo" => break PermissionMode::Yolo,
1038                    _ => writeln!(output, "Permissions must be `guardian` or `yolo`.")?,
1039                }
1040            };
1041            break SshTargetKind::Bare { permissions };
1042        }
1043        let kind = if matches!(runtime.as_str(), "" | "y" | "yes" | "podman") {
1044            SshTargetKind::Podman {
1045                image: String::new(),
1046            }
1047        } else if runtime == "docker" {
1048            SshTargetKind::Docker {
1049                image: String::new(),
1050            }
1051        } else {
1052            writeln!(
1053                output,
1054                "Runtime must be `podman`, `docker`, or `bare`; please choose again."
1055            )?;
1056            continue;
1057        };
1058        let image = prompt(
1059            input,
1060            output,
1061            &format!("Container image [{DEFAULT_IMAGE}]: "),
1062        )?;
1063        let image = if image.is_empty() {
1064            DEFAULT_IMAGE.to_owned()
1065        } else {
1066            image
1067        };
1068        break match kind {
1069            SshTargetKind::Podman { .. } => SshTargetKind::Podman { image },
1070            SshTargetKind::Docker { .. } => SshTargetKind::Docker { image },
1071            SshTargetKind::Bare { .. } => unreachable!("bare runtime returned above"),
1072        };
1073    };
1074
1075    let Some(name) = prompt_ssh_target_name(input, output, &host, configured)? else {
1076        return Ok(None);
1077    };
1078
1079    Ok(Some(SshTargetInput { name, host, kind }))
1080}
1081
1082/// Ask for the SSH target's name until the answer is a usable target id.
1083///
1084/// Both failures are caught here rather than by `config.validate()` after every
1085/// question has been asked: an invalid id would otherwise discard the whole
1086/// dialog, and a name that is already taken would silently replace the target
1087/// it collides with.
1088fn prompt_ssh_target_name(
1089    input: &mut impl SetupPrompter,
1090    output: &mut impl Write,
1091    host: &str,
1092    configured: &BTreeMap<String, TargetTemplate>,
1093) -> Result<Option<String>> {
1094    loop {
1095        let Some(answer) = prompt_line(input, output, &format!("Target name [{host}]: "))? else {
1096            writeln!(output, "Input ended; skipping the SSH target.")?;
1097            return Ok(None);
1098        };
1099        let name = if answer.is_empty() {
1100            host.to_owned()
1101        } else {
1102            answer
1103        };
1104        if let Err(error) = validate_id("target", &name) {
1105            writeln!(output, "{error}")?;
1106            continue;
1107        }
1108        if configured.contains_key(&name) {
1109            writeln!(
1110                output,
1111                "Target {name} is already configured; choose another name."
1112            )?;
1113            continue;
1114        }
1115        return Ok(Some(name));
1116    }
1117}
1118
1119/// Phrase a failed setup smoke test the way `mj doctor --smoke` phrases the
1120/// same failure, so it joins the closing report instead of ending the run.
1121fn smoke_failure_check(runtime: RuntimeKind, image: &str, error: &anyhow::Error) -> DoctorCheck {
1122    let scope = match runtime {
1123        RuntimeKind::Docker => "Disposable run/exec/remove and OverlayFS attachment smoke test",
1124        RuntimeKind::Podman | RuntimeKind::AppleContainer => {
1125            "Disposable run/exec/remove smoke test"
1126        }
1127    };
1128    DoctorCheck::fixable(
1129        format!("runtime.{}.smoke", runtime.id()),
1130        format!("{} smoke test", runtime.label()),
1131        format!("{scope} failed for image {image}: {error:#}"),
1132        format!(
1133            "Fix the configured image or the {} runtime, then run `mj doctor --smoke` again.",
1134            runtime.label()
1135        ),
1136    )
1137}
1138
1139/// End setup with the same report `mj doctor` prints, so the user gets one
1140/// ready/fixable summary with remediations instead of two different signals.
1141///
1142/// `extra` carries anything setup itself learned that doctor cannot repeat
1143/// without the opt-in smoke test.
1144fn write_doctor_report(
1145    output: &mut impl Write,
1146    config_path: &Path,
1147    executor: &impl CommandExecutor,
1148    extra: Vec<DoctorCheck>,
1149) -> Result<()> {
1150    writeln!(output)?;
1151    writeln!(output, "Running `mj doctor` checks on the new config...")?;
1152    let mut checks = run_with_config_path(
1153        config_path,
1154        executor,
1155        current_apple_platform(executor),
1156        DoctorOptions { smoke: false },
1157    );
1158    checks.extend(extra);
1159    render_human(&checks, output)?;
1160    if all_ready(&checks) {
1161        writeln!(output, "Every check is ready.")?;
1162    } else {
1163        writeln!(
1164            output,
1165            "Apply the remediations above, then rerun `mj doctor`."
1166        )?;
1167    }
1168    Ok(())
1169}
1170
1171fn prompt(input: &mut impl SetupPrompter, output: &mut impl Write, label: &str) -> Result<String> {
1172    Ok(prompt_line(input, output, label)?.unwrap_or_default())
1173}
1174
1175/// Read one answer, reporting `None` once the input has ended.
1176///
1177/// Every question but one treats the end of input as an empty answer and takes
1178/// its default. A question that must be asked again until it is answered needs
1179/// the difference, or it would loop forever against a closed stdin.
1180fn prompt_line(
1181    input: &mut impl SetupPrompter,
1182    output: &mut impl Write,
1183    label: &str,
1184) -> Result<Option<String>> {
1185    input.read_prompt(output, label)
1186}
1187
1188trait SetupPrompter {
1189    fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>>;
1190}
1191
1192impl<R: BufRead> SetupPrompter for R {
1193    fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>> {
1194        write!(output, "{label}")?;
1195        output.flush()?;
1196        let mut answer = String::new();
1197        let read = self.read_line(&mut answer).context("read setup response")?;
1198        Ok((read > 0).then(|| answer.trim().to_owned()))
1199    }
1200}
1201
1202#[derive(Default)]
1203struct ReadlinePrompter(crate::hel_readline::LineReader);
1204
1205impl SetupPrompter for ReadlinePrompter {
1206    fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>> {
1207        output.flush()?;
1208        self.0.read_line(label).context("read setup response")
1209    }
1210}
1211
1212fn write_summary(
1213    output: &mut impl Write,
1214    config_path: &Path,
1215    config: &HelConfig,
1216    runtimes: &[(RuntimeKind, String)],
1217) -> Result<()> {
1218    writeln!(output, "Mjolnir will write {} with:", config_path.display())?;
1219    writeln!(output, "  {} profile(s)", config.profiles.len())?;
1220    writeln!(output, "  {} bundle(s)", config.bundles.len())?;
1221    writeln!(
1222        output,
1223        "  raw localhost target using configured harness homes directly"
1224    )?;
1225    for (runtime, _) in runtimes {
1226        let target = config
1227            .targets
1228            .get(runtime.id())
1229            .expect("configured runtime target exists");
1230        let image = match target {
1231            TargetTemplate::LocalPodman { container }
1232            | TargetTemplate::LocalDocker { container }
1233            | TargetTemplate::AppleContainer { container } => &container.image,
1234            _ => unreachable!("setup runtime target is a local container"),
1235        };
1236        writeln!(output, "  {} target using {image}", runtime.label())?;
1237    }
1238    if let Some(TargetTemplate::AwsEc2 {
1239        launch_template,
1240        region,
1241        ..
1242    }) = config.targets.get(AWS_TARGET_ID)
1243    {
1244        writeln!(
1245            output,
1246            "  AWS EC2 target using launch template {launch_template} in {region}"
1247        )?;
1248    }
1249    for (id, target) in &config.targets {
1250        match target {
1251            TargetTemplate::SshBare { ssh, .. } => {
1252                writeln!(output, "  SSH target {id} on {} (no container)", ssh.host)?;
1253            }
1254            TargetTemplate::SshPodman { ssh, container, .. } => {
1255                writeln!(
1256                    output,
1257                    "  SSH target {id} on {} using Podman image {}",
1258                    ssh.host, container.image
1259                )?;
1260            }
1261            TargetTemplate::SshDocker { ssh, container } => {
1262                writeln!(
1263                    output,
1264                    "  SSH target {id} on {} using Docker image {}",
1265                    ssh.host, container.image
1266                )?;
1267            }
1268            _ => {}
1269        }
1270    }
1271    if config_path.exists() {
1272        writeln!(output, "  This replaces the existing configuration file.")?;
1273    }
1274    Ok(())
1275}
1276
1277fn smoke_target(runtime: RuntimeKind, image: &str) -> RuntimeTargetTemplate {
1278    let container = RuntimeContainerTemplate {
1279        image: image.to_owned(),
1280        pull_policy: Default::default(),
1281        extra_run_args: vec![],
1282        workspace_storage: Default::default(),
1283    };
1284    match runtime {
1285        RuntimeKind::Podman => RuntimeTargetTemplate::LocalPodman(container),
1286        RuntimeKind::Docker => RuntimeTargetTemplate::LocalDocker(container),
1287        RuntimeKind::AppleContainer => RuntimeTargetTemplate::AppleContainer(container),
1288    }
1289}
1290
1291fn run_smoke_test(
1292    output: &mut impl Write,
1293    target: &RuntimeTargetTemplate,
1294    executor: &impl CommandExecutor,
1295) -> Result<()> {
1296    let smoke_id = format!(
1297        "setup-{}-{:x}",
1298        std::process::id(),
1299        SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos()
1300    );
1301    let description = match target {
1302        RuntimeTargetTemplate::LocalDocker(_) => {
1303            "Smoke test: verifying a disposable container and writable OverlayFS attachment..."
1304        }
1305        _ => "Smoke test: verifying a disposable container...",
1306    };
1307    writeln!(output, "{description}")?;
1308    run_setup_smoke_test(target, &smoke_id, executor)
1309}
1310
1311#[cfg(test)]
1312mod tests {
1313    use std::cell::RefCell;
1314    use std::fs;
1315
1316    use super::*;
1317    use hel::hel_targets::CommandOutput;
1318
1319    #[cfg(unix)]
1320    #[test]
1321    fn first_terminal_launch_writes_a_local_codex_config_once() {
1322        let directory = tempfile::tempdir().unwrap();
1323        let path = directory.path().join("config.toml");
1324        initialize_local_startup_config(&path).unwrap();
1325        let config = HelConfig::load_from(&path).unwrap();
1326        assert_eq!(config.profiles["codex"].kind, HarnessKind::Codex);
1327        assert!(config.profiles["codex"].home.is_absolute());
1328        assert!(matches!(
1329            config.targets["localhost"],
1330            TargetTemplate::LocalBare
1331        ));
1332        assert!(config.bundles.is_empty());
1333        let written = fs::read(&path).unwrap();
1334        initialize_local_startup_config(&path).unwrap();
1335        assert_eq!(fs::read(&path).unwrap(), written);
1336    }
1337
1338    #[cfg(unix)]
1339    #[test]
1340    fn local_startup_preserves_existing_settings_and_ignores_disabled_startup() {
1341        let directory = tempfile::tempdir().unwrap();
1342        let path = directory.path().join("config.toml");
1343        let mut config = HelConfig::default();
1344        config.phone.enabled = false;
1345        config.save_to(&path).unwrap();
1346        initialize_local_startup_config(&path).unwrap();
1347        assert!(!HelConfig::load_from(&path).unwrap().phone.enabled);
1348
1349        // Even a partially configured installation belongs to the user.
1350        let configured =
1351            "version = 2\n# keep this comment\n[targets.custom]\nkind = 'local-bare'\n";
1352        fs::write(&path, configured).unwrap();
1353        initialize_local_startup_config(&path).unwrap();
1354        assert_eq!(fs::read_to_string(&path).unwrap(), configured);
1355
1356        let disabled = "version = 2\n[startup]\nenabled = false\n";
1357        fs::write(&path, disabled).unwrap();
1358        initialize_local_startup_config(&path).unwrap();
1359        let bootstrapped = HelConfig::load_from(&path).unwrap();
1360        assert!(!bootstrapped.startup.enabled);
1361        assert_eq!(bootstrapped.profiles["codex"].kind, HarnessKind::Codex);
1362        assert!(matches!(
1363            bootstrapped.targets["localhost"],
1364            TargetTemplate::LocalBare
1365        ));
1366
1367        let newer = "version = 999\nfuture_field = true\n";
1368        fs::write(&path, newer).unwrap();
1369        initialize_local_startup_config(&path).unwrap();
1370        assert_eq!(fs::read_to_string(&path).unwrap(), newer);
1371    }
1372
1373    struct FakeExecutor {
1374        commands: RefCell<Vec<CommandSpec>>,
1375        statuses: Vec<i32>,
1376    }
1377
1378    impl FakeExecutor {
1379        fn succeeds() -> Self {
1380            Self {
1381                commands: RefCell::new(vec![]),
1382                statuses: vec![0, 0, 0],
1383            }
1384        }
1385    }
1386
1387    impl CommandExecutor for FakeExecutor {
1388        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1389            let index = self.commands.borrow().len();
1390            self.commands.borrow_mut().push(command.clone());
1391            Ok(CommandOutput {
1392                status: self.statuses.get(index).copied().unwrap_or(0),
1393                stdout: b"available".to_vec(),
1394                stderr: b"failed".to_vec(),
1395            })
1396        }
1397    }
1398
1399    struct RuntimeProbeExecutor {
1400        commands: RefCell<Vec<CommandSpec>>,
1401        outputs: RefCell<Vec<CommandOutput>>,
1402    }
1403
1404    impl RuntimeProbeExecutor {
1405        fn new(outputs: impl IntoIterator<Item = CommandOutput>) -> Self {
1406            Self {
1407                commands: RefCell::new(vec![]),
1408                outputs: RefCell::new(outputs.into_iter().collect()),
1409            }
1410        }
1411    }
1412
1413    impl CommandExecutor for RuntimeProbeExecutor {
1414        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1415            self.commands.borrow_mut().push(command.clone());
1416            if self.outputs.borrow().is_empty() {
1417                anyhow::bail!("no canned output for {}", command.program);
1418            }
1419            Ok(self.outputs.borrow_mut().remove(0))
1420        }
1421    }
1422
1423    fn ok(stdout: &[u8]) -> CommandOutput {
1424        CommandOutput {
1425            status: 0,
1426            stdout: stdout.to_vec(),
1427            stderr: vec![],
1428        }
1429    }
1430
1431    fn failed(stderr: &[u8]) -> CommandOutput {
1432        CommandOutput {
1433            status: 1,
1434            stdout: vec![],
1435            stderr: stderr.to_vec(),
1436        }
1437    }
1438
1439    const CALLER_IDENTITY: &[u8] =
1440        br#"{"UserId":"AIDA","Account":"123456789012","Arn":"arn:aws:iam::123456789012:user/dev"}"#;
1441
1442    fn discovery_without_runtimes() -> SetupDiscovery {
1443        SetupDiscovery {
1444            homes: vec![],
1445            repository: None,
1446            runtimes: vec![],
1447            aws: None,
1448            ssh_hosts: vec![],
1449        }
1450    }
1451
1452    #[test]
1453    fn discovers_default_and_overridden_homes_with_authentication_markers() {
1454        let directory = tempfile::tempdir().unwrap();
1455        let home = directory.path().join("home");
1456        let codex = home.join(".codex");
1457        let kimi = home.join(".kimi-code");
1458        let grok = home.join(".grok");
1459        let deepseek = home.join(".dsh");
1460        let claude = directory.path().join("claude-override");
1461        fs::create_dir_all(&codex).unwrap();
1462        fs::create_dir_all(kimi.join("credentials")).unwrap();
1463        fs::create_dir_all(&grok).unwrap();
1464        fs::create_dir_all(&deepseek).unwrap();
1465        fs::create_dir_all(&claude).unwrap();
1466        fs::write(codex.join("auth.json"), "{}").unwrap();
1467        fs::write(kimi.join("credentials/kimi-code.json"), "{}").unwrap();
1468        fs::write(grok.join("auth.json"), "{}").unwrap();
1469        fs::write(deepseek.join(".credentials.yaml"), "version: 1\n").unwrap();
1470        fs::write(claude.join(".credentials.json"), "{}").unwrap();
1471
1472        let executor = FakeExecutor::succeeds();
1473        let homes = discover_harness_homes_with_executor(
1474            Some(&home),
1475            [(HarnessKind::Claude, claude.clone())],
1476            &executor,
1477        );
1478
1479        assert_eq!(homes.len(), 5);
1480        assert!(homes.iter().all(|home| home.authenticated));
1481        assert!(homes.iter().any(|home| home.path == codex));
1482        assert!(homes.iter().any(|home| home.path == claude));
1483        assert!(homes.iter().any(|home| home.path == kimi));
1484        assert!(
1485            homes
1486                .iter()
1487                .any(|home| { home.path == deepseek && home.kind == HarnessKind::Deepseek })
1488        );
1489        assert!(
1490            homes
1491                .iter()
1492                .any(|home| home.path == grok && home.kind == HarnessKind::Grok)
1493        );
1494    }
1495
1496    #[test]
1497    fn every_harness_has_a_discoverable_default_home() {
1498        let directory = tempfile::tempdir().unwrap();
1499        let home = directory.path().to_path_buf();
1500        for kind in HarnessKind::ALL {
1501            fs::create_dir_all(home.join(kind.default_home_leaf())).unwrap();
1502        }
1503
1504        let executor = FakeExecutor::succeeds();
1505        let homes = discover_harness_homes_with_executor(Some(&home), [], &executor);
1506
1507        assert_eq!(homes.len(), HarnessKind::ALL.len());
1508        for kind in HarnessKind::ALL {
1509            assert!(
1510                homes
1511                    .iter()
1512                    .any(|home| home.kind == kind && !home.authenticated),
1513                "{kind:?} default home"
1514            );
1515        }
1516    }
1517
1518    #[cfg(target_os = "macos")]
1519    #[test]
1520    fn claude_keychain_marks_the_default_home_authenticated_without_a_marker() {
1521        let directory = tempfile::tempdir().unwrap();
1522        let home = directory.path().join("home");
1523        let claude = home.join(".claude");
1524        fs::create_dir_all(&claude).unwrap();
1525        let executor = RuntimeProbeExecutor::new([ok(
1526            br#"{"claudeAiOauth":{"accessToken":"access","refreshToken":"refresh"}}"#,
1527        )]);
1528
1529        let homes = discover_harness_homes_with_executor(Some(&home), [], &executor);
1530
1531        assert_eq!(
1532            homes,
1533            vec![DiscoveredHome {
1534                kind: HarnessKind::Claude,
1535                path: claude.clone(),
1536                authenticated: true,
1537            }]
1538        );
1539        let commands = executor.commands.borrow();
1540        assert_eq!(commands.len(), 1);
1541        assert_eq!(commands[0].program, "security");
1542        assert_eq!(
1543            commands[0].args,
1544            [
1545                "find-generic-password",
1546                "-s",
1547                "Claude Code-credentials",
1548                "-w"
1549            ]
1550        );
1551        assert!(commands[0].env.is_empty());
1552    }
1553
1554    #[test]
1555    fn claude_status_checks_a_custom_home_without_a_marker() {
1556        let directory = tempfile::tempdir().unwrap();
1557        let claude = directory.path().join("claude-custom");
1558        fs::create_dir_all(&claude).unwrap();
1559        let executor =
1560            RuntimeProbeExecutor::new([ok(br#"{"loggedIn":true,"authMethod":"claude.ai"}"#)]);
1561
1562        let homes = discover_harness_homes_with_executor(
1563            None,
1564            [(HarnessKind::Claude, claude.clone())],
1565            &executor,
1566        );
1567
1568        assert_eq!(
1569            homes,
1570            vec![DiscoveredHome {
1571                kind: HarnessKind::Claude,
1572                path: claude.clone(),
1573                authenticated: true,
1574            }]
1575        );
1576        let commands = executor.commands.borrow();
1577        assert_eq!(commands.len(), 1);
1578        assert_eq!(commands[0].program, "claude");
1579        assert_eq!(commands[0].args, ["auth", "status", "--json"]);
1580        assert_eq!(
1581            commands[0].env.get("CLAUDE_CONFIG_DIR"),
1582            Some(&claude.to_string_lossy().into_owned())
1583        );
1584    }
1585
1586    #[test]
1587    fn claude_credential_evidence_requires_a_nonempty_login_secret() {
1588        assert!(claude_credentials_contain_login(
1589            br#"{"claudeAiOauth":{"refreshToken":"refresh"}}"#
1590        ));
1591        assert!(!claude_credentials_contain_login(
1592            br#"{"claudeAiOauth":{"refreshToken":"  "}}"#
1593        ));
1594        assert!(!claude_credentials_contain_login(b"not json"));
1595    }
1596
1597    #[test]
1598    fn github_origin_parser_accepts_standard_https_and_ssh_forms() {
1599        for origin in [
1600            "https://github.com/BrokkAi/hel.git",
1601            "git@github.com:BrokkAi/hel.git",
1602            "ssh://git@github.com/BrokkAi/hel.git",
1603        ] {
1604            assert_eq!(
1605                github_repository_from_origin(origin),
1606                Some(GithubRepository {
1607                    owner: "BrokkAi".into(),
1608                    repository: "hel".into(),
1609                })
1610            );
1611        }
1612        assert_eq!(
1613            github_repository_from_origin("https://example.com/hel"),
1614            None
1615        );
1616    }
1617
1618    #[test]
1619    fn config_contains_discovered_profiles_current_repository_and_selected_target() {
1620        let homes = vec![
1621            DiscoveredHome {
1622                kind: HarnessKind::Codex,
1623                path: PathBuf::from("/profiles/codex"),
1624                authenticated: true,
1625            },
1626            DiscoveredHome {
1627                kind: HarnessKind::Codex,
1628                path: PathBuf::from("/profiles/codex-two"),
1629                authenticated: false,
1630            },
1631        ];
1632        let repository = GithubRepository {
1633            owner: "BrokkAi".into(),
1634            repository: "hel".into(),
1635        };
1636
1637        let config = build_config(
1638            &homes,
1639            Some(&repository),
1640            RuntimeKind::Podman,
1641            "ubuntu:24.04",
1642        );
1643
1644        config.validate().unwrap();
1645        assert!(config.profiles.contains_key("codex"));
1646        assert!(config.profiles.contains_key("codex-2"));
1647        assert_eq!(
1648            config.bundles["current-repository"].repositories[0]
1649                .github
1650                .as_deref(),
1651            Some("BrokkAi/hel")
1652        );
1653        assert!(matches!(
1654            config.targets["podman"],
1655            TargetTemplate::LocalPodman { .. }
1656        ));
1657        assert!(matches!(
1658            config.targets["localhost"],
1659            TargetTemplate::LocalBare
1660        ));
1661
1662        let docker = build_config(
1663            &homes,
1664            Some(&repository),
1665            RuntimeKind::Docker,
1666            "ubuntu:24.04",
1667        );
1668        assert!(matches!(
1669            docker.targets["docker"],
1670            TargetTemplate::LocalDocker { .. }
1671        ));
1672    }
1673
1674    #[test]
1675    fn runtime_probe_requires_podman_rootless_preflight_and_checks_apple_on_macos() {
1676        let executor = RuntimeProbeExecutor::new([
1677            ok(b"podman version 5.4.2\n"),
1678            ok(b"true\n"),
1679            ok(b"0 1000 1\n1 100000 65536\n"),
1680            ok(b"29.0.1 linux\n"),
1681            ok(b"container version 1\n"),
1682            ok(b"running\n"),
1683        ]);
1684        let runtimes = probe_local_runtimes(&executor, true);
1685
1686        assert_eq!(runtimes.len(), 3);
1687        assert_eq!(executor.commands.borrow()[0].program, "podman");
1688        assert_eq!(executor.commands.borrow()[0].args, ["--version"]);
1689        assert_eq!(
1690            executor.commands.borrow()[1].args,
1691            ["info", "--format", "{{.Host.Security.Rootless}}"]
1692        );
1693        assert_eq!(
1694            executor.commands.borrow()[2].args,
1695            ["unshare", "cat", "/proc/self/uid_map"]
1696        );
1697        assert_eq!(executor.commands.borrow()[3].program, "docker");
1698        assert_eq!(executor.commands.borrow()[4].program, "container");
1699        assert!(runtimes.iter().all(|runtime| runtime.usable));
1700    }
1701
1702    #[test]
1703    fn unusable_podman_carries_the_doctor_remediation_into_the_runtime_list() {
1704        let executor = RuntimeProbeExecutor::new([
1705            ok(b"podman version 3.4.7\n"),
1706            failed(b"docker is unavailable"),
1707        ]);
1708
1709        let runtimes = probe_local_runtimes(&executor, false);
1710
1711        assert_eq!(runtimes.len(), 2);
1712        assert!(!runtimes[0].usable);
1713        let remediation = runtimes[0].remediation.as_deref().unwrap();
1714        assert!(remediation.contains("Upgrade Podman"), "{remediation}");
1715
1716        let mut output = Vec::new();
1717        write_runtimes(&mut output, &runtimes).unwrap();
1718        let output = String::from_utf8(output).unwrap();
1719        assert!(output.contains("Podman: unavailable"), "{output}");
1720        assert!(output.contains("Docker: unavailable"), "{output}");
1721        assert!(output.contains("remediation: Upgrade Podman"), "{output}");
1722    }
1723
1724    #[test]
1725    fn aws_is_detected_only_when_the_caller_identity_call_succeeds() {
1726        let missing = RuntimeProbeExecutor::new([]);
1727        assert_eq!(detect_aws(&missing), None);
1728
1729        let denied = RuntimeProbeExecutor::new([failed(b"ExpiredToken")]);
1730        assert_eq!(detect_aws(&denied), None);
1731
1732        let working = RuntimeProbeExecutor::new([ok(CALLER_IDENTITY), ok(b"us-east-1\n")]);
1733        assert_eq!(
1734            detect_aws(&working),
1735            Some(AwsAccount {
1736                account: "123456789012".into(),
1737                arn: "arn:aws:iam::123456789012:user/dev".into(),
1738                region: Some("us-east-1".into()),
1739            })
1740        );
1741        assert_eq!(working.commands.borrow()[0].args[0], "sts");
1742        assert_eq!(
1743            working.commands.borrow()[1].args,
1744            ["configure", "get", "region"]
1745        );
1746    }
1747
1748    #[test]
1749    fn aws_detection_without_a_configured_region_leaves_the_region_unset() {
1750        let executor = RuntimeProbeExecutor::new([ok(CALLER_IDENTITY), failed(b"")]);
1751
1752        assert_eq!(detect_aws(&executor).unwrap().region, None);
1753    }
1754
1755    #[test]
1756    fn the_aws_step_asks_nothing_when_no_aws_credentials_were_detected() {
1757        let mut input = b"".as_slice();
1758        let mut output = Vec::new();
1759
1760        let aws = prompt_aws_target(&mut input, &mut output, None).unwrap();
1761
1762        assert_eq!(aws, None);
1763        let output = String::from_utf8(output).unwrap();
1764        assert!(output.contains("skipping the AWS target"), "{output}");
1765        assert!(!output.contains("[y/N]"), "{output}");
1766    }
1767
1768    #[test]
1769    fn the_aws_step_defaults_region_and_ssh_user_when_the_answers_are_blank() {
1770        let account = AwsAccount {
1771            account: "123456789012".into(),
1772            arn: "arn:aws:iam::123456789012:user/dev".into(),
1773            region: Some("us-east-1".into()),
1774        };
1775        let mut input = b"y\nhel-runson\n\n\n\n".as_slice();
1776        let mut output = Vec::new();
1777
1778        let aws = prompt_aws_target(&mut input, &mut output, Some(&account))
1779            .unwrap()
1780            .unwrap();
1781
1782        assert_eq!(
1783            aws,
1784            AwsTargetInput {
1785                launch_template: "hel-runson".into(),
1786                region: "us-east-1".into(),
1787                ssh_user: DEFAULT_AWS_SSH_USER.into(),
1788                identity_file: None,
1789            }
1790        );
1791        let config = build_config_with_runtime(&[], None, None, Some(&aws), None);
1792        let TargetTemplate::AwsEc2 {
1793            region,
1794            launch_template,
1795            ssh_user,
1796            ..
1797        } = &config.targets[AWS_TARGET_ID]
1798        else {
1799            panic!("setup must write an aws-ec2 target");
1800        };
1801        assert_eq!(region, "us-east-1");
1802        assert_eq!(launch_template, "hel-runson");
1803        assert_eq!(ssh_user, DEFAULT_AWS_SSH_USER);
1804        config.validate().unwrap();
1805    }
1806
1807    const SSH_CONFIG_FIXTURE: &str = r#"
1808# Personal hosts
1809Host *
1810    ServerAliveInterval 60
1811
1812Host builder build.example.com
1813    HostName build.example.com
1814    User dev
1815
1816Host bastion
1817  HostName 10.0.0.1
1818  IdentityFile ~/.ssh/id_ed25519
1819
1820Host prod-*
1821    User deploy
1822
1823Host !staging *.internal
1824    User deploy
1825
1826Host builder
1827    Compression yes
1828"#;
1829
1830    #[test]
1831    fn ssh_config_parsing_keeps_concrete_aliases_and_drops_pattern_blocks() {
1832        let aliases = ssh_config_aliases(SSH_CONFIG_FIXTURE);
1833
1834        assert_eq!(
1835            aliases,
1836            vec!["builder", "build.example.com", "bastion"],
1837            "wildcard, negated, and duplicate entries must not appear"
1838        );
1839    }
1840
1841    #[test]
1842    fn ssh_config_parsing_returns_nothing_for_a_config_of_only_wildcards() {
1843        assert!(
1844            ssh_config_aliases(
1845                "Host *
1846  User dev
1847"
1848            )
1849            .is_empty()
1850        );
1851        assert!(ssh_config_aliases("").is_empty());
1852    }
1853
1854    #[test]
1855    fn the_ssh_step_asks_nothing_when_the_ssh_config_has_no_aliases() {
1856        let mut input = b"".as_slice();
1857        let mut output = Vec::new();
1858
1859        assert_eq!(
1860            prompt_ssh_target(&mut input, &mut output, &[], &BTreeMap::new()).unwrap(),
1861            None
1862        );
1863        let output = String::from_utf8(output).unwrap();
1864        assert!(output.contains("skipping the SSH target"), "{output}");
1865        assert!(!output.contains("[y/N]"), "{output}");
1866    }
1867
1868    #[test]
1869    fn declining_the_ssh_step_writes_no_ssh_target() {
1870        let aliases = vec!["builder".to_owned()];
1871        let mut input = b"\n".as_slice();
1872        let mut output = Vec::new();
1873
1874        assert_eq!(
1875            prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new()).unwrap(),
1876            None
1877        );
1878        let config = build_config_with_runtime(&[], None, None, None, None);
1879        assert!(!config.targets.values().any(|target| matches!(
1880            target,
1881            TargetTemplate::SshBare { .. }
1882                | TargetTemplate::SshPodman { .. }
1883                | TargetTemplate::SshDocker { .. }
1884        )));
1885    }
1886
1887    #[test]
1888    fn accepting_the_ssh_step_can_write_a_docker_target() {
1889        let aliases = vec!["builder".to_owned()];
1890        // yes, host 1, Docker, default image, and the default target name.
1891        let mut input = b"y\n1\ndocker\n\n\n".as_slice();
1892        let mut output = Vec::new();
1893
1894        let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new())
1895            .unwrap()
1896            .unwrap();
1897        assert_eq!(
1898            ssh.kind,
1899            SshTargetKind::Docker {
1900                image: DEFAULT_IMAGE.into()
1901            }
1902        );
1903        let config = build_config_with_runtime(&[], None, None, None, Some(&ssh));
1904        let TargetTemplate::SshDocker { ssh, container } = &config.targets["builder"] else {
1905            panic!("setup must write an ssh-docker target");
1906        };
1907        assert_eq!(ssh.host, "builder");
1908        assert_eq!(container.image, DEFAULT_IMAGE);
1909        config.validate().unwrap();
1910    }
1911
1912    #[test]
1913    fn the_ssh_step_rejects_an_unknown_runtime_before_asking_for_an_image() {
1914        let aliases = vec!["builder".to_owned()];
1915        let mut input = b"y\n1\ncontainerd\ndocker\n\n\n".as_slice();
1916        let mut output = Vec::new();
1917
1918        let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new())
1919            .unwrap()
1920            .unwrap();
1921
1922        assert!(matches!(ssh.kind, SshTargetKind::Docker { .. }));
1923        let output = String::from_utf8(output).unwrap();
1924        assert!(output.contains("Runtime must be"), "{output}");
1925        assert_eq!(output.matches("Container image").count(), 1);
1926    }
1927
1928    #[test]
1929    fn accepting_the_ssh_step_writes_an_ssh_podman_target_with_the_default_image() {
1930        let aliases = vec!["builder".to_owned(), "bastion".to_owned()];
1931        // yes, host 1, podman (default), default image and name.
1932        let mut input = b"y\n1\n\n\n\n".as_slice();
1933        let mut output = Vec::new();
1934
1935        let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new())
1936            .unwrap()
1937            .unwrap();
1938
1939        assert_eq!(
1940            ssh,
1941            SshTargetInput {
1942                name: "builder".into(),
1943                host: "builder".into(),
1944                kind: SshTargetKind::Podman {
1945                    image: DEFAULT_IMAGE.into()
1946                },
1947            }
1948        );
1949        let config = build_config_with_runtime(&[], None, None, None, Some(&ssh));
1950        let TargetTemplate::SshPodman { ssh, container, .. } = &config.targets["builder"] else {
1951            panic!("setup must write an ssh-podman target");
1952        };
1953        assert_eq!(ssh.host, "builder");
1954        assert_eq!(ssh.user, None);
1955        assert_eq!(ssh.identity_file, None);
1956        assert_eq!(container.image, DEFAULT_IMAGE);
1957        config.validate().unwrap();
1958    }
1959
1960    #[test]
1961    fn accepting_the_ssh_step_writes_an_ssh_bare_target_under_a_chosen_name() {
1962        let aliases = vec!["builder".to_owned()];
1963        // yes, typed host, default guardian permissions, no podman, custom name.
1964        let mut input = b"y\nother.example.com\nn\n\nremote\n".as_slice();
1965        let mut output = Vec::new();
1966
1967        let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new())
1968            .unwrap()
1969            .unwrap();
1970
1971        assert_eq!(
1972            ssh,
1973            SshTargetInput {
1974                name: "remote".into(),
1975                host: "other.example.com".into(),
1976                kind: SshTargetKind::Bare {
1977                    permissions: PermissionMode::Guardian,
1978                },
1979            }
1980        );
1981        let config = build_config_with_runtime(&[], None, None, None, Some(&ssh));
1982        let TargetTemplate::SshBare {
1983            ssh, permissions, ..
1984        } = &config.targets["remote"]
1985        else {
1986            panic!("setup must write an ssh-bare target");
1987        };
1988        assert_eq!(ssh.host, "other.example.com");
1989        assert_eq!(*permissions, PermissionMode::Guardian);
1990        config.validate().unwrap();
1991    }
1992
1993    #[test]
1994    fn the_ssh_step_reasks_until_the_name_is_a_free_and_valid_target_id() {
1995        let aliases = vec!["builder".to_owned()];
1996        let configured = build_config_with_runtime(
1997            &[],
1998            None,
1999            Some((RuntimeKind::Podman, DEFAULT_IMAGE)),
2000            None,
2001            None,
2002        )
2003        .targets;
2004        // yes, host 1, no podman, then: a name that is already taken, a name
2005        // that is not a usable id, and finally a free one.
2006        let mut input = b"y\n1\nn\n\npodman\nbuild host\nbuilder\n".as_slice();
2007        let mut output = Vec::new();
2008
2009        let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &configured)
2010            .unwrap()
2011            .unwrap();
2012
2013        assert_eq!(ssh.name, "builder");
2014        let output = String::from_utf8(output).unwrap();
2015        assert!(
2016            output.contains("Target podman is already configured"),
2017            "{output}"
2018        );
2019        assert!(output.contains("invalid target id"), "{output}");
2020    }
2021
2022    #[test]
2023    fn the_ssh_step_stops_asking_for_a_name_once_the_input_ends() {
2024        let aliases = vec!["podman".to_owned()];
2025        let configured = build_config_with_runtime(
2026            &[],
2027            None,
2028            Some((RuntimeKind::Podman, DEFAULT_IMAGE)),
2029            None,
2030            None,
2031        )
2032        .targets;
2033        // yes, host 1, no podman, then nothing: the default name collides, so
2034        // the question can never be answered.
2035        let mut input = b"y\n1\nn\n\n".as_slice();
2036        let mut output = Vec::new();
2037
2038        assert_eq!(
2039            prompt_ssh_target(&mut input, &mut output, &aliases, &configured).unwrap(),
2040            None
2041        );
2042        let output = String::from_utf8(output).unwrap();
2043        assert!(output.contains("Input ended; skipping"), "{output}");
2044    }
2045
2046    #[test]
2047    fn an_ssh_target_never_replaces_a_target_configured_earlier() {
2048        let ssh = SshTargetInput {
2049            name: "podman".into(),
2050            host: "builder".into(),
2051            kind: SshTargetKind::Bare {
2052                permissions: PermissionMode::Guardian,
2053            },
2054        };
2055
2056        let config = build_config_with_runtime(
2057            &[],
2058            None,
2059            Some((RuntimeKind::Podman, DEFAULT_IMAGE)),
2060            None,
2061            Some(&ssh),
2062        );
2063
2064        assert!(matches!(
2065            config.targets["podman"],
2066            TargetTemplate::LocalPodman { .. }
2067        ));
2068        assert!(matches!(
2069            config.targets["podman-2"],
2070            TargetTemplate::SshBare { .. }
2071        ));
2072        config.validate().unwrap();
2073    }
2074
2075    #[test]
2076    fn the_github_origin_is_discovered_through_the_shared_executor() {
2077        let executor = RuntimeProbeExecutor::new([ok(b"git@github.com:BrokkAi/hel.git\n")]);
2078
2079        let repository = discover_github_repository(&executor, Path::new("/work/hel")).unwrap();
2080
2081        assert_eq!(repository.source(), "BrokkAi/hel");
2082        let commands = executor.commands.borrow();
2083        assert_eq!(commands[0].program, "git");
2084        assert_eq!(
2085            commands[0].args,
2086            ["-C", "/work/hel", "remote", "get-url", "origin"]
2087        );
2088    }
2089
2090    #[test]
2091    fn no_github_origin_is_reported_when_the_probe_fails() {
2092        let failing = RuntimeProbeExecutor::new([failed(b"not a git repository")]);
2093        assert_eq!(
2094            discover_github_repository(&failing, Path::new("/work/plain")),
2095            None
2096        );
2097
2098        let missing = RuntimeProbeExecutor::new([]);
2099        assert_eq!(
2100            discover_github_repository(&missing, Path::new("/work/plain")),
2101            None
2102        );
2103    }
2104
2105    #[test]
2106    fn declining_the_aws_step_writes_no_aws_target() {
2107        let account = AwsAccount {
2108            account: "123456789012".into(),
2109            arn: "arn:aws:iam::123456789012:user/dev".into(),
2110            region: None,
2111        };
2112        let mut input = b"\n".as_slice();
2113        let mut output = Vec::new();
2114
2115        assert_eq!(
2116            prompt_aws_target(&mut input, &mut output, Some(&account)).unwrap(),
2117            None
2118        );
2119        let config = build_config_with_runtime(&[], None, None, None, None);
2120        assert!(!config.targets.contains_key(AWS_TARGET_ID));
2121    }
2122
2123    #[test]
2124    fn smoke_test_removes_the_container_after_a_failed_command() {
2125        let executor = FakeExecutor {
2126            commands: RefCell::new(vec![]),
2127            statuses: vec![0, 1, 0],
2128        };
2129        let mut output = Vec::new();
2130
2131        assert!(
2132            run_smoke_test(
2133                &mut output,
2134                &smoke_target(RuntimeKind::Podman, "ubuntu:24.04"),
2135                &executor
2136            )
2137            .is_err()
2138        );
2139        let commands = executor.commands.borrow();
2140        assert_eq!(commands.len(), 3);
2141        assert_eq!(commands[2].args[0], "rm");
2142    }
2143
2144    #[test]
2145    fn docker_smoke_test_exercises_the_managed_overlay_attachment_path() {
2146        let executor = FakeExecutor::succeeds();
2147        let mut output = Vec::new();
2148
2149        run_smoke_test(
2150            &mut output,
2151            &smoke_target(RuntimeKind::Docker, "ubuntu:24.04"),
2152            &executor,
2153        )
2154        .unwrap();
2155
2156        let commands = executor.commands.borrow();
2157        assert_eq!(commands.len(), 3);
2158        assert_eq!(commands[0].program, "sh");
2159        assert!(commands[0].args[1].contains("docker volume create"));
2160        assert!(commands[0].args[1].contains("type=overlay"));
2161        assert_eq!(commands[1].program, "docker");
2162        assert_eq!(commands[1].args[0], "exec");
2163        assert_eq!(commands[2].program, "sh");
2164        assert!(commands[2].args[1].contains("docker volume rm --force"));
2165        assert!(
2166            String::from_utf8(output)
2167                .unwrap()
2168                .contains("writable OverlayFS attachment")
2169        );
2170    }
2171
2172    #[test]
2173    fn dialog_configures_every_usable_runtime_as_a_normal_target() {
2174        let directory = tempfile::tempdir().unwrap();
2175        let config_path = directory.path().join("config.toml");
2176        let discovery = SetupDiscovery {
2177            homes: vec![DiscoveredHome {
2178                kind: HarnessKind::Codex,
2179                path: PathBuf::from("/profiles/codex"),
2180                authenticated: true,
2181            }],
2182            repository: Some(GithubRepository {
2183                owner: "BrokkAi".into(),
2184                repository: "hel".into(),
2185            }),
2186            runtimes: vec![
2187                RuntimeProbe {
2188                    kind: RuntimeKind::Podman,
2189                    usable: true,
2190                    detail: "podman version 5".into(),
2191                    remediation: None,
2192                },
2193                RuntimeProbe {
2194                    kind: RuntimeKind::Docker,
2195                    usable: true,
2196                    detail: "docker version 29".into(),
2197                    remediation: None,
2198                },
2199            ],
2200            aws: None,
2201            ssh_hosts: vec![],
2202        };
2203        let executor = FakeExecutor::succeeds();
2204        let mut input = b"\ny\n".as_slice();
2205        let mut output = Vec::new();
2206
2207        assert_eq!(
2208            run_setup_dialog_with(
2209                &mut input,
2210                &mut output,
2211                &config_path,
2212                &discovery,
2213                &executor,
2214                &executor,
2215            )
2216            .unwrap(),
2217            SetupOutcome::Written
2218        );
2219        assert!(config_path.exists());
2220        let config = HelConfig::load_from(&config_path).unwrap();
2221        assert!(matches!(
2222            config.targets["podman"],
2223            TargetTemplate::LocalPodman { .. }
2224        ));
2225        assert!(matches!(
2226            config.targets["docker"],
2227            TargetTemplate::LocalDocker { .. }
2228        ));
2229        let smoke = executor.commands.borrow()[..3]
2230            .iter()
2231            .map(|command| command.args[0].clone())
2232            .collect::<Vec<_>>();
2233        assert_eq!(smoke, ["run", "exec", "rm"]);
2234        let commands = executor.commands.borrow();
2235        assert!(commands.len() >= 6);
2236        assert_eq!(commands[3].program, "sh");
2237        assert_eq!(commands[4].program, "docker");
2238        assert_eq!(commands[5].program, "sh");
2239        drop(commands);
2240        let output = String::from_utf8(output).unwrap();
2241        assert!(output.contains("Podman target using"), "{output}");
2242        assert!(output.contains("Docker target using"), "{output}");
2243        assert!(!output.contains("Recommended runtime"), "{output}");
2244        assert!(!output.contains("Runtime ("), "{output}");
2245        assert!(output.ends_with("Press n to start your first session.\n"));
2246    }
2247
2248    #[test]
2249    fn a_failed_smoke_test_becomes_a_fixable_line_in_the_closing_report() {
2250        let directory = tempfile::tempdir().unwrap();
2251        let config_path = directory.path().join("config.toml");
2252        let discovery = SetupDiscovery {
2253            runtimes: vec![RuntimeProbe {
2254                kind: RuntimeKind::Podman,
2255                usable: true,
2256                detail: "podman version 5".into(),
2257                remediation: None,
2258            }],
2259            ..discovery_without_runtimes()
2260        };
2261        // Create the container, fail the command inside it, remove it.
2262        let executor = FakeExecutor {
2263            commands: RefCell::new(vec![]),
2264            statuses: vec![0, 1, 0],
2265        };
2266        let mut input = b"\ny\n".as_slice();
2267        let mut output = Vec::new();
2268
2269        let outcome = run_setup_dialog_with(
2270            &mut input,
2271            &mut output,
2272            &config_path,
2273            &discovery,
2274            &executor,
2275            &executor,
2276        )
2277        .unwrap();
2278
2279        assert_eq!(outcome, SetupOutcome::Written);
2280        assert!(config_path.exists());
2281        let output = String::from_utf8(output).unwrap();
2282        assert!(output.contains("fixable Podman smoke test"), "{output}");
2283        assert!(
2284            output.contains("remediation: Fix the configured image or the Podman runtime"),
2285            "{output}"
2286        );
2287        // The report the user was promised still runs, and still ends with the
2288        // instruction to apply the remediations it just listed.
2289        assert!(
2290            output.contains("Running `mj doctor` checks on the new config..."),
2291            "{output}"
2292        );
2293        assert!(
2294            output.contains("Apply the remediations above, then rerun `mj doctor`."),
2295            "{output}"
2296        );
2297        assert!(
2298            output.ends_with("Press n to start your first session.\n"),
2299            "{output}"
2300        );
2301    }
2302
2303    #[test]
2304    fn setup_finishes_with_the_standard_doctor_report_for_the_config_it_wrote() {
2305        let directory = tempfile::tempdir().unwrap();
2306        let config_path = directory.path().join("config.toml");
2307        let discovery = SetupDiscovery {
2308            homes: vec![DiscoveredHome {
2309                kind: HarnessKind::Codex,
2310                path: directory.path().join("missing-codex-home"),
2311                authenticated: false,
2312            }],
2313            ..discovery_without_runtimes()
2314        };
2315        let executor = FakeExecutor::succeeds();
2316        let mut input = b"y\n".as_slice();
2317        let mut output = Vec::new();
2318
2319        run_setup_dialog_with(
2320            &mut input,
2321            &mut output,
2322            &config_path,
2323            &discovery,
2324            &executor,
2325            &executor,
2326        )
2327        .unwrap();
2328
2329        let output = String::from_utf8(output).unwrap();
2330        // The report is doctor's own rendering: a status-prefixed line per
2331        // check, plus the remediation doctor would print for the missing home.
2332        assert!(
2333            output.contains(&format!(
2334                "ready Mjolnir configuration: {} is valid",
2335                config_path.display()
2336            )),
2337            "{output}"
2338        );
2339        assert!(output.contains("fixable Harness profile codex"), "{output}");
2340        assert!(
2341            output.contains("  remediation: Create or select the Codex home"),
2342            "{output}"
2343        );
2344        assert!(
2345            output.contains("Apply the remediations above, then rerun `mj doctor`."),
2346            "{output}"
2347        );
2348    }
2349
2350    #[test]
2351    fn dialog_configures_raw_localhost_without_a_container_runtime() {
2352        let directory = tempfile::tempdir().unwrap();
2353        let config_path = directory.path().join("config.toml");
2354        let discovery = SetupDiscovery {
2355            homes: vec![DiscoveredHome {
2356                kind: HarnessKind::Kimi,
2357                path: PathBuf::from("/profiles/kimi"),
2358                authenticated: true,
2359            }],
2360            repository: None,
2361            runtimes: vec![RuntimeProbe {
2362                kind: RuntimeKind::Podman,
2363                usable: false,
2364                detail: "not installed".into(),
2365                remediation: Some("Install Podman.".into()),
2366            }],
2367            aws: None,
2368            ssh_hosts: vec![],
2369        };
2370        let executor = FakeExecutor::succeeds();
2371        let mut input = b"y\n".as_slice();
2372        let mut output = Vec::new();
2373
2374        assert_eq!(
2375            run_setup_dialog_with(
2376                &mut input,
2377                &mut output,
2378                &config_path,
2379                &discovery,
2380                &executor,
2381                &executor,
2382            )
2383            .unwrap(),
2384            SetupOutcome::Written
2385        );
2386        let config = HelConfig::load_from(&config_path).unwrap();
2387        assert!(matches!(
2388            config.targets["localhost"],
2389            TargetTemplate::LocalBare
2390        ));
2391        // No smoke test runs without a runtime; the trailing commands belong to
2392        // the doctor report.
2393        assert!(
2394            executor
2395                .commands
2396                .borrow()
2397                .iter()
2398                .all(|command| command.program != "podman" || command.args[0] != "run")
2399        );
2400        let output = String::from_utf8(output).unwrap();
2401        assert!(output.contains("DANGER"));
2402        assert!(output.contains("has no guardian approval mode"));
2403        assert!(output.contains("raw localhost will still be configured"));
2404    }
2405
2406    #[test]
2407    fn discovered_homes_warn_for_harnesses_without_guardian_approvals() {
2408        let warning = |kind: HarnessKind| {
2409            let mut output = Vec::new();
2410            write_discovered_homes(
2411                &mut output,
2412                &[DiscoveredHome {
2413                    kind,
2414                    path: PathBuf::from("/profiles/harness"),
2415                    authenticated: true,
2416                }],
2417            )
2418            .unwrap();
2419            String::from_utf8(output).unwrap()
2420        };
2421
2422        for kind in [HarnessKind::Kimi, HarnessKind::Deepseek] {
2423            let output = warning(kind);
2424            assert!(output.contains("DANGER"), "{kind:?}: {output}");
2425            assert!(
2426                output.contains("has no guardian approval mode"),
2427                "{kind:?}: {output}"
2428            );
2429            assert!(output.contains("raw, unsandboxed target"), "{output}");
2430        }
2431
2432        for kind in [HarnessKind::Codex, HarnessKind::Claude, HarnessKind::Grok] {
2433            assert!(!warning(kind).contains("DANGER"), "{kind:?}");
2434        }
2435    }
2436}