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