Skip to main content

mj_controller/
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, ensure};
9
10use crate::doctor::{
11    CheckStatus, DoctorCheck, DoctorOptions, all_ready, apple_container_daemon_check,
12    current_apple_platform, local_docker_runtime_check, local_podman_runtime_check, probe_executor,
13    render_human, run_with_config_path,
14};
15use crate::targets::{
16    CancellableProcessExecutor, CommandExecutor, CommandSpec,
17    ContainerTemplate as RuntimeContainerTemplate, ProcessExecutor,
18    TargetTemplate as RuntimeTargetTemplate, run_setup_smoke_test,
19};
20use mj_core::config::{
21    AwsAddressSource, Config, ContainerTemplate, HarnessHost, HarnessKind, HarnessProfile,
22    PermissionMode, ProjectBundle, ProjectRepository, SshConnection, TargetTemplate,
23    unique_config_id as unique_id, validate_id,
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    pub 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.
151///
152/// Only when Codex is on this machine, though: its home exists or the `codex`
153/// command is on PATH. Otherwise nothing is written. The dashboard then opens
154/// on the Get started panel, which says no agent was found, and the next
155/// launch after Codex is installed adds the profile (launch finding R13-1).
156pub fn initialize_local_startup_config(config_path: &Path) -> Result<()> {
157    #[cfg(unix)]
158    {
159        let kind = HarnessKind::Codex;
160        initialize_local_startup_config_with(
161            config_path,
162            || {
163                std::env::var_os(kind.home_env())
164                    .map(|value| kind.home_from_environment(value))
165                    .or_else(|| dirs::home_dir().map(|home| home.join(kind.default_home_leaf())))
166                    .context("locate Codex home for the default local profile")
167            },
168            || {
169                crate::targets::program_on_path(
170                    kind.cli_binary_name(),
171                    std::env::var_os("PATH").as_deref(),
172                )
173            },
174        )?;
175    }
176    // Local bare targets are unsupported on Windows; retain explicit setup.
177    #[cfg(not(unix))]
178    let _ = config_path;
179    Ok(())
180}
181
182/// [`initialize_local_startup_config`] with the two facts it reads from this
183/// machine supplied: where the Codex home would be, and whether the `codex`
184/// command is installed.
185#[cfg(unix)]
186fn initialize_local_startup_config_with(
187    config_path: &Path,
188    codex_home: impl FnOnce() -> Result<PathBuf>,
189    codex_on_path: impl FnOnce() -> bool,
190) -> Result<()> {
191    let config = Config::load_from(config_path)?;
192    if !config.is_unconfigured() {
193        return Ok(());
194    }
195    let home = codex_home()?;
196    if !home.exists() && !codex_on_path() {
197        return Ok(());
198    }
199    let home = std::path::absolute(home).context("resolve Codex profile home")?;
200    Config::update_to(config_path, |fresh| {
201        if fresh.is_unconfigured() {
202            configure_local_startup(fresh, home);
203        }
204        Ok(())
205    })?;
206    Ok(())
207}
208
209#[cfg(unix)]
210fn configure_local_startup(config: &mut Config, codex_home: PathBuf) {
211    config.profiles.insert(
212        "codex".into(),
213        HarnessProfile {
214            enabled: true,
215            kind: HarnessKind::Codex,
216            home: codex_home,
217            environment: BTreeMap::new(),
218            context_window_bytes: None,
219            guardian_review_model: None,
220        },
221    );
222    config
223        .targets
224        .insert("localhost".into(), TargetTemplate::LocalBare);
225}
226
227/// Run the setup dialog using the user's normal standard input and output.
228pub fn run_setup_dialog(config_path: &Path) -> Result<SetupOutcome> {
229    // Prerequisite probes run under doctor's per-probe deadline, so a wedged
230    // container socket cannot stall the first run; only the smoke test, which
231    // may pull an image, is allowed to take as long as it needs.
232    let probes = probe_executor();
233    let discovery = discover_current(&probes);
234    let stdout = io::stdout();
235    let mut input = ReadlinePrompter::default();
236    run_setup_dialog_inner(
237        &mut input,
238        &mut stdout.lock(),
239        config_path,
240        &discovery,
241        &ProcessExecutor,
242        &probes,
243    )
244}
245
246pub fn discover_current(executor: &impl CommandExecutor) -> SetupDiscovery {
247    let home = dirs::home_dir();
248    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
249
250    SetupDiscovery {
251        homes: discover_profiles(executor),
252        repository: discover_github_repository(executor, &cwd),
253        runtimes: discover_runtimes(executor),
254        aws: detect_aws(&CancellableProcessExecutor::with_timeout(AWS_PROBE_TIMEOUT)),
255        ssh_hosts: discover_ssh_hosts(home.as_deref()),
256    }
257}
258
259/// The installed agent homes alone. Callers that only add profiles use this
260/// instead of `discover_current`, which also runs container, AWS, and SSH
261/// probes whose results they would discard.
262pub fn discover_profiles(executor: &impl CommandExecutor) -> Vec<DiscoveredHome> {
263    let home = dirs::home_dir();
264    let overrides = HarnessKind::ALL
265        .into_iter()
266        .filter_map(|kind| {
267            std::env::var_os(kind.home_env()).map(|path| (kind, kind.home_from_environment(path)))
268        })
269        .collect::<BTreeMap<_, _>>();
270    let mut homes =
271        discover_harness_homes_with_executor(home.as_deref(), overrides.clone(), executor);
272    discover_installed_harnesses(home.as_deref(), &overrides, &mut homes, executor);
273    homes
274}
275
276/// The container runtimes on this machine alone, usable or not, so a caller
277/// can report why an unusable one was skipped.
278pub fn discover_runtimes(executor: &impl CommandExecutor) -> Vec<RuntimeProbe> {
279    probe_local_runtimes(executor, cfg!(target_os = "macos"))
280}
281
282/// A configuration holding the discovered profiles and nothing else, for
283/// merging into an existing configuration.
284pub fn profiles_config(homes: &[DiscoveredHome]) -> Config {
285    build_config_with_runtimes(homes, None, &[], None, None)
286}
287
288/// Read the concrete `Host` aliases from `~/.ssh/config`.
289///
290/// This is a pure read: setup never runs `ssh` while discovering. `Include`
291/// directives are deliberately not followed, because resolving them correctly
292/// means reimplementing OpenSSH's glob and relative-path rules; aliases that
293/// live in an included file simply are not offered, and the user can still
294/// type a host by hand.
295pub fn discover_ssh_hosts(home: Option<&Path>) -> Vec<String> {
296    let Some(home) = home else {
297        return Vec::new();
298    };
299    let Ok(contents) = std::fs::read_to_string(home.join(".ssh").join("config")) else {
300        return Vec::new();
301    };
302    ssh_config_aliases(&contents)
303}
304
305/// Extract the usable `Host` aliases from SSH config text.
306///
307/// Pattern entries (`*`, `?`, `!`) are skipped: they configure other hosts
308/// rather than naming one Hel could connect to.
309pub fn ssh_config_aliases(contents: &str) -> Vec<String> {
310    let mut aliases: Vec<String> = Vec::new();
311    for line in contents.lines() {
312        let line = line.trim();
313        if line.is_empty() || line.starts_with('#') {
314            continue;
315        }
316        let Some((keyword, rest)) = line.split_once(char::is_whitespace) else {
317            continue;
318        };
319        if !keyword.eq_ignore_ascii_case("host") {
320            continue;
321        }
322        for alias in rest.split_whitespace() {
323            let alias = alias.trim_matches('"');
324            if alias.is_empty() || alias.contains(['*', '?', '!']) {
325                continue;
326            }
327            if !aliases.iter().any(|existing| existing == alias) {
328                aliases.push(alias.to_owned());
329            }
330        }
331    }
332    aliases
333}
334
335/// A newly installed CLI may not create its profile directory until login.
336/// Run these probes through setup's bounded, cancellable executor.
337fn discover_installed_harnesses(
338    user_home: Option<&Path>,
339    overrides: &BTreeMap<HarnessKind, PathBuf>,
340    homes: &mut Vec<DiscoveredHome>,
341    executor: &impl CommandExecutor,
342) {
343    for kind in HarnessKind::ALL {
344        if homes.iter().any(|home| home.kind == kind) {
345            continue;
346        }
347        let Some(home) = overrides
348            .get(&kind)
349            .cloned()
350            .or_else(|| user_home.map(|home| home.join(kind.default_home_leaf())))
351        else {
352            continue;
353        };
354        let probe = CommandSpec::new(kind.cli_binary_name(), ["--version"])
355            .purpose("detect installed harness before first login");
356        match executor.execute(&probe) {
357            Ok(output) if output.status == 0 => homes.push(DiscoveredHome {
358                kind,
359                path: home,
360                authenticated: false,
361            }),
362            Ok(_) => {}
363            Err(error) => tracing::debug!(
364                harness = kind.id(),
365                "installation probe unavailable: {error:#}"
366            ),
367        }
368    }
369}
370
371pub(crate) fn discover_harness_homes_with_executor(
372    home: Option<&Path>,
373    overrides: impl IntoIterator<Item = (HarnessKind, PathBuf)>,
374    executor: &impl CommandExecutor,
375) -> Vec<DiscoveredHome> {
376    let mut candidates = Vec::new();
377    if let Some(home) = home {
378        candidates.extend(
379            HarnessKind::ALL
380                .into_iter()
381                .map(|kind| (kind, home.join(kind.default_home_leaf()), true)),
382        );
383    }
384    candidates.extend(
385        overrides
386            .into_iter()
387            .map(|(kind, path)| (kind, path, false)),
388    );
389
390    let mut seen = BTreeSet::new();
391    candidates
392        .into_iter()
393        .filter(|(kind, path, _)| seen.insert((*kind, path.clone())) && path.is_dir())
394        .map(|(kind, path, is_default_home)| DiscoveredHome {
395            authenticated: harness_is_authenticated_with(
396                &probe_profile(kind, &path),
397                is_default_home,
398                executor,
399            ),
400            kind,
401            path,
402        })
403        .collect()
404}
405
406/// A profile standing in for a home discovery found but the user has not
407/// configured. It carries no environment, which is all the authentication gate
408/// needs: how a profile authenticates is decided by its home, not its key.
409fn probe_profile(kind: HarnessKind, home: &Path) -> HarnessProfile {
410    HarnessProfile {
411        enabled: true,
412        kind,
413        home: home.to_path_buf(),
414        environment: BTreeMap::new(),
415        context_window_bytes: None,
416        guardian_review_model: None,
417    }
418}
419
420pub(crate) fn harness_is_authenticated_with_executor(
421    profile: &HarnessProfile,
422    executor: &impl CommandExecutor,
423) -> bool {
424    let is_default_home = dirs::home_dir()
425        .is_some_and(|user_home| profile.home == user_home.join(profile.kind.default_home_leaf()));
426    harness_is_authenticated_with(profile, is_default_home, executor)
427}
428
429/// Whether this profile can talk to its service without a login first.
430///
431/// An API-key profile is proven by its harness configuration file, because its
432/// key lives in the profile's `environment` rather than in a credential file;
433/// [`HarnessProfile::authentication_marker`] already names the right file for
434/// either case.
435fn harness_is_authenticated_with(
436    profile: &HarnessProfile,
437    is_default_home: bool,
438    executor: &impl CommandExecutor,
439) -> bool {
440    let kind = profile.kind;
441    let home = profile.home.as_path();
442    if profile.authentication_marker().is_file()
443        || (kind == HarnessKind::Kimi && home.join("credentials").is_file())
444    {
445        return true;
446    }
447    if kind != HarnessKind::Claude {
448        return false;
449    }
450    // Where the login does not live in the home, every Claude profile shares
451    // the one Keychain item, so asking the CLI about a scoped home would only
452    // report the default profile's state again.
453    if is_default_home || !kind.keeps_login_in_home(HarnessHost::current()) {
454        return claude_keychain_reports_authenticated(executor);
455    }
456    claude_cli_reports_authenticated(home, executor)
457}
458
459/// Ask Claude Code about a scoped profile. Setting `CLAUDE_CONFIG_DIR` for the
460/// default home changes Claude's profile selection, so the default macOS
461/// profile is checked directly in the Keychain instead.
462fn claude_cli_reports_authenticated(home: &Path, executor: &impl CommandExecutor) -> bool {
463    let mut command = CommandSpec::new("claude", ["auth", "status", "--json"])
464        .purpose("check Claude Code authentication");
465    command.env.insert(
466        HarnessKind::Claude.home_env().to_owned(),
467        home.to_string_lossy().into_owned(),
468    );
469    let Ok(output) = executor.execute(&command) else {
470        return false;
471    };
472    if output.status != 0 {
473        return false;
474    }
475    serde_json::from_slice::<serde_json::Value>(&output.stdout)
476        .ok()
477        .and_then(|status| status.get("loggedIn").and_then(serde_json::Value::as_bool))
478        == Some(true)
479}
480
481/// Mjolnir and Claude Code use this service for the default macOS profile.
482/// `security` is already authorized for the item, so this does not raise a
483/// Keychain prompt; the shared executor still bounds a wedged lookup.
484#[cfg(target_os = "macos")]
485fn claude_keychain_reports_authenticated(executor: &impl CommandExecutor) -> bool {
486    let command = CommandSpec::new(
487        "security",
488        [
489            "find-generic-password",
490            "-s",
491            "Claude Code-credentials",
492            "-w",
493        ],
494    )
495    .purpose("check Claude Code authentication in the macOS Keychain");
496    let Ok(output) = executor.execute(&command) else {
497        return false;
498    };
499    output.status == 0 && claude_credentials_contain_login(&output.stdout)
500}
501
502#[cfg(not(target_os = "macos"))]
503fn claude_keychain_reports_authenticated(_executor: &impl CommandExecutor) -> bool {
504    false
505}
506
507#[cfg(any(target_os = "macos", test))]
508fn claude_credentials_contain_login(credentials: &[u8]) -> bool {
509    let Ok(document) = serde_json::from_slice::<serde_json::Value>(credentials) else {
510        return false;
511    };
512    [
513        "/claudeAiOauth/accessToken",
514        "/claudeAiOauth/refreshToken",
515        "/oauth/accessToken",
516        "/apiKey",
517    ]
518    .into_iter()
519    .any(|pointer| {
520        document
521            .pointer(pointer)
522            .and_then(serde_json::Value::as_str)
523            .is_some_and(|value| !value.trim().is_empty())
524    })
525}
526
527pub fn github_repository_from_origin(origin: &str) -> Option<GithubRepository> {
528    let origin = origin.trim();
529    let path = origin
530        .strip_prefix("https://github.com/")
531        .or_else(|| origin.strip_prefix("http://github.com/"))
532        .or_else(|| origin.strip_prefix("git@github.com:"))
533        .or_else(|| origin.strip_prefix("ssh://git@github.com/"))
534        // Config accepts owner/repository shorthand, and import uses the same
535        // parser to compare that configured source with `git remote` output.
536        .unwrap_or(origin);
537    let path = path.trim_end_matches(".git");
538    let mut parts = path.split('/');
539    let owner = parts.next()?;
540    let repository = parts.next()?;
541    if owner.is_empty()
542        || repository.is_empty()
543        || parts.next().is_some()
544        || owner.chars().any(char::is_whitespace)
545        || repository.chars().any(char::is_whitespace)
546    {
547        return None;
548    }
549    Some(GithubRepository {
550        owner: owner.to_owned(),
551        repository: repository.to_owned(),
552    })
553}
554
555/// Read the current directory's GitHub origin, through the same executor every
556/// other discovery probe uses so it is bounded and can be faked in tests.
557///
558/// `git -C` selects the directory instead of a working-directory field on the
559/// command, which no executor carries.
560fn discover_github_repository(
561    executor: &impl CommandExecutor,
562    cwd: &Path,
563) -> Option<GithubRepository> {
564    let command = CommandSpec::new(
565        "git",
566        [
567            "-C".to_owned(),
568            cwd.to_string_lossy().into_owned(),
569            "remote".to_owned(),
570            "get-url".to_owned(),
571            "origin".to_owned(),
572        ],
573    )
574    .purpose("detect the current repository's GitHub origin");
575    let output = executor.execute(&command).ok()?;
576    if output.status != 0 {
577        return None;
578    }
579    github_repository_from_origin(&String::from_utf8_lossy(&output.stdout))
580}
581
582/// Probe the container runtimes setup can configure, reusing the doctor checks
583/// so an unavailable runtime carries doctor's detail and remediation.
584pub fn probe_local_runtimes(executor: &impl CommandExecutor, is_macos: bool) -> Vec<RuntimeProbe> {
585    let mut probes = vec![
586        runtime_probe_from_check(RuntimeKind::Podman, local_podman_runtime_check(executor)),
587        runtime_probe_from_check(RuntimeKind::Docker, local_docker_runtime_check(executor)),
588    ];
589    if is_macos {
590        probes.push(runtime_probe_from_check(
591            RuntimeKind::AppleContainer,
592            apple_container_daemon_check(executor),
593        ));
594    }
595    probes
596}
597
598fn runtime_probe_from_check(kind: RuntimeKind, check: crate::doctor::DoctorCheck) -> RuntimeProbe {
599    RuntimeProbe {
600        kind,
601        usable: check.status == CheckStatus::Ready,
602        detail: check.detail,
603        remediation: check.remediation,
604    }
605}
606
607/// Detect a usable AWS CLI identity on this host.
608///
609/// Returns `None` whenever the CLI is missing or its credentials do not work,
610/// so setup can skip the AWS step instead of prompting for a target that could
611/// never launch.
612pub fn detect_aws(executor: &impl CommandExecutor) -> Option<AwsAccount> {
613    let identity = CommandSpec::new("aws", ["sts", "get-caller-identity", "--output", "json"])
614        .purpose("detect AWS credentials");
615    let output = executor.execute(&identity).ok()?;
616    if output.status != 0 {
617        return None;
618    }
619    let identity: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
620    let account = identity.get("Account")?.as_str()?.to_owned();
621    let arn = identity.get("Arn")?.as_str()?.to_owned();
622    Some(AwsAccount {
623        account,
624        arn,
625        region: configured_aws_region(executor),
626    })
627}
628
629fn configured_aws_region(executor: &impl CommandExecutor) -> Option<String> {
630    let command = CommandSpec::new("aws", ["configure", "get", "region"])
631        .purpose("read the default AWS region");
632    let output = executor.execute(&command).ok()?;
633    if output.status != 0 {
634        return None;
635    }
636    let region = String::from_utf8_lossy(&output.stdout).trim().to_owned();
637    (!region.is_empty()).then_some(region)
638}
639
640pub fn build_config(
641    homes: &[DiscoveredHome],
642    repository: Option<&GithubRepository>,
643    runtime: RuntimeKind,
644    image: &str,
645) -> Config {
646    build_config_with_runtime(homes, repository, Some((runtime, image)), None, None)
647}
648
649fn build_config_with_runtime(
650    homes: &[DiscoveredHome],
651    repository: Option<&GithubRepository>,
652    runtime: Option<(RuntimeKind, &str)>,
653    aws: Option<&AwsTargetInput>,
654    ssh: Option<&SshTargetInput>,
655) -> Config {
656    build_config_with_runtimes(
657        homes,
658        repository,
659        &runtime.into_iter().collect::<Vec<_>>(),
660        aws,
661        ssh,
662    )
663}
664
665fn build_config_with_runtimes(
666    homes: &[DiscoveredHome],
667    repository: Option<&GithubRepository>,
668    runtimes: &[(RuntimeKind, &str)],
669    aws: Option<&AwsTargetInput>,
670    ssh: Option<&SshTargetInput>,
671) -> Config {
672    let mut config = Config::default();
673    for home in homes {
674        let id = unique_id(&config.profiles, home.kind.id());
675        config.profiles.insert(
676            id,
677            HarnessProfile {
678                enabled: true,
679                kind: home.kind,
680                home: home.path.clone(),
681                environment: BTreeMap::new(),
682                context_window_bytes: None,
683                guardian_review_model: None,
684            },
685        );
686    }
687
688    if let Some(repository) = repository {
689        let repository_id = config_id(&repository.repository);
690        config.bundles.insert(
691            "current-repository".to_owned(),
692            ProjectBundle {
693                primary_repo: repository_id.clone(),
694                repositories: vec![ProjectRepository {
695                    id: repository_id.clone(),
696                    github: Some(repository.source()),
697                    local: None,
698                    destination: PathBuf::from(repository_id),
699                    git_ref: None,
700                }],
701            },
702        );
703    }
704
705    #[cfg(unix)]
706    config
707        .targets
708        .insert("localhost".to_owned(), TargetTemplate::LocalBare);
709    for (runtime, image) in runtimes {
710        let (target_id, target) = local_runtime_target(*runtime, image);
711        config.targets.insert(target_id.to_owned(), target);
712    }
713    if let Some(aws) = aws {
714        config.targets.insert(
715            AWS_TARGET_ID.to_owned(),
716            TargetTemplate::AwsEc2 {
717                aws_profile: None,
718                region: aws.region.clone(),
719                launch_template: aws.launch_template.clone(),
720                launch_template_version: None,
721                ssh_user: aws.ssh_user.clone(),
722                address_source: AwsAddressSource::default(),
723                identity_file: aws.identity_file.clone(),
724                ssh_args: vec![],
725            },
726        );
727    }
728    if let Some(ssh) = ssh {
729        // Leave user and identity file unset: the SSH config alias already
730        // carries whatever the user configured for this host.
731        let connection = SshConnection {
732            host: ssh.host.clone(),
733            user: None,
734            identity_file: None,
735            extra_args: vec![],
736        };
737        let target = match &ssh.kind {
738            SshTargetKind::Bare { permissions } => TargetTemplate::SshBare {
739                ssh: connection,
740                permissions: *permissions,
741                workspace_prefix: default_ssh_workspace_prefix(),
742            },
743            SshTargetKind::Podman { image } => TargetTemplate::SshPodman {
744                ssh: connection,
745                container: ContainerTemplate {
746                    build_cache: None,
747                    image: image.clone(),
748                    pull_policy: Default::default(),
749                    platform: None,
750                    cpus: None,
751                    memory: None,
752                    environment: BTreeMap::new(),
753                    workspace_storage: Default::default(),
754                },
755            },
756            SshTargetKind::Docker { image } => TargetTemplate::SshDocker {
757                ssh: connection,
758                container: ContainerTemplate {
759                    build_cache: None,
760                    image: image.clone(),
761                    pull_policy: Default::default(),
762                    platform: None,
763                    cpus: None,
764                    memory: None,
765                    environment: BTreeMap::new(),
766                    workspace_storage: Default::default(),
767                },
768            },
769        };
770        // The dialog already refuses a name that collides, so this only guards
771        // a caller that builds a config without asking: a chosen SSH name must
772        // never silently replace a target configured moments earlier.
773        config
774            .targets
775            .insert(unique_id(&config.targets, &ssh.name), target);
776    }
777    config
778}
779
780/// The shared setup/startup template for a locally available container engine.
781pub fn local_runtime_target(runtime: RuntimeKind, image: &str) -> (&'static str, TargetTemplate) {
782    let container = ContainerTemplate {
783        build_cache: None,
784        image: image.trim().to_owned(),
785        pull_policy: Default::default(),
786        platform: None,
787        cpus: None,
788        memory: None,
789        environment: BTreeMap::new(),
790        workspace_storage: Default::default(),
791    };
792    match runtime {
793        RuntimeKind::Podman => ("podman", TargetTemplate::LocalPodman { container }),
794        RuntimeKind::Docker => ("docker", TargetTemplate::LocalDocker { container }),
795        RuntimeKind::AppleContainer => (
796            "apple-container",
797            TargetTemplate::AppleContainer { container },
798        ),
799    }
800}
801
802/// A new machine's directory. The file names it, so a machine written here
803/// never falls back to the former directory a hand-written one keeps.
804fn default_ssh_workspace_prefix() -> PathBuf {
805    PathBuf::from(mj_core::config::DEFAULT_WORKSPACE_PREFIX)
806}
807
808fn config_id(value: &str) -> String {
809    let mut id = value
810        .chars()
811        .filter(|character| {
812            character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
813        })
814        .take(64)
815        .collect::<String>();
816    if id.is_empty() || matches!(id.as_str(), "." | "..") {
817        id = "repository".to_owned();
818    }
819    id
820}
821
822/// Ask the setup questions, write the configuration, and report on it.
823///
824/// The smoke test and the closing doctor report run through different
825/// executors on purpose: a smoke test may pull a multi-gigabyte image and must
826/// not be given a deadline, while every prerequisite probe must answer quickly
827/// or be reported as a fixable check.
828pub fn run_setup_dialog_with(
829    input: &mut impl BufRead,
830    output: &mut impl Write,
831    config_path: &Path,
832    discovery: &SetupDiscovery,
833    smoke_executor: &impl CommandExecutor,
834    probe_executor: &impl CommandExecutor,
835) -> Result<SetupOutcome> {
836    run_setup_dialog_inner(
837        input,
838        output,
839        config_path,
840        discovery,
841        smoke_executor,
842        probe_executor,
843    )
844}
845
846fn run_setup_dialog_inner(
847    input: &mut impl SetupPrompter,
848    output: &mut impl Write,
849    config_path: &Path,
850    discovery: &SetupDiscovery,
851    smoke_executor: &impl CommandExecutor,
852    probe_executor: &impl CommandExecutor,
853) -> Result<SetupOutcome> {
854    let existing = Config::load_from(config_path)?;
855    writeln!(output, "Welcome to Mjolnir setup.")?;
856    writeln!(output)?;
857    write_discovered_homes(output, &discovery.homes)?;
858    write_repository(output, discovery.repository.as_ref())?;
859    write_runtimes(output, &discovery.runtimes)?;
860
861    let runtimes = if discovery.runtimes.iter().any(|runtime| runtime.usable) {
862        let image = prompt(
863            input,
864            output,
865            &format!("Container image [{DEFAULT_IMAGE}]: "),
866        )?;
867        let image = if image.is_empty() {
868            DEFAULT_IMAGE.to_owned()
869        } else {
870            image
871        };
872        discovery
873            .runtimes
874            .iter()
875            .filter(|runtime| runtime.usable)
876            .map(|runtime| (runtime.kind, image.clone()))
877            .collect::<Vec<_>>()
878    } else {
879        writeln!(
880            output,
881            "No usable container runtime found; raw localhost will still be configured."
882        )?;
883        Vec::new()
884    };
885    let aws = prompt_aws_target(input, output, discovery.aws.as_ref())?;
886    let runtime_choices = runtimes
887        .iter()
888        .map(|(runtime, image)| (*runtime, image.as_str()))
889        .collect::<Vec<_>>();
890    // Build what the earlier answers already claimed, so the SSH step can
891    // refuse a target name that would replace one of them.
892    let configured = build_config_with_runtimes(
893        &discovery.homes,
894        discovery.repository.as_ref(),
895        &runtime_choices,
896        aws.as_ref(),
897        None,
898    );
899    let ssh = prompt_ssh_target(input, output, &discovery.ssh_hosts, &configured.targets)?;
900    let config = build_config_with_runtimes(
901        &discovery.homes,
902        discovery.repository.as_ref(),
903        &runtime_choices,
904        aws.as_ref(),
905        ssh.as_ref(),
906    );
907    config.validate()?;
908    let additions = reconcile_setup(input, output, &existing, config)?;
909    let runtimes = runtimes
910        .into_iter()
911        .filter(|(runtime, image)| {
912            let (_, target) = local_runtime_target(*runtime, image);
913            additions.targets.values().any(|added| added == &target)
914        })
915        .collect::<Vec<_>>();
916
917    writeln!(output)?;
918    write_summary(output, config_path, &additions, &runtimes)?;
919    let confirmation = prompt(input, output, "Write this configuration? [y/N]: ")?;
920    if !matches!(confirmation.to_ascii_lowercase().as_str(), "y" | "yes") {
921        writeln!(output, "Setup cancelled.")?;
922        return Ok(SetupOutcome::Cancelled);
923    }
924
925    writeln!(output, "Writing {}...", config_path.display())?;
926    let (written, ()) = Config::update_to(config_path, |latest| {
927        apply_setup_additions(latest, &additions)
928    })?;
929    // A failed smoke test is a fixable prerequisite, not a reason to abandon
930    // the run: the configuration is already written, and this is exactly when
931    // the closing report's remediations matter most.
932    let smoke_failures = runtimes
933        .iter()
934        .filter_map(|(runtime, image)| {
935            let target = smoke_target(*runtime, image);
936            run_smoke_test(output, &target, smoke_executor)
937                .err()
938                .map(|error| smoke_failure_check(*runtime, image, &error))
939        })
940        .collect();
941    write_doctor_report(output, config_path, probe_executor, smoke_failures)?;
942    writeln!(
943        output,
944        "Advanced users can edit TOML for extra profiles, virtual monorepos, SSH, and AWS."
945    )?;
946    writeln!(output, "{}", setup_next_step(&written))?;
947    Ok(SetupOutcome::Written)
948}
949
950/// Setup's last line: what to do next with the configuration it wrote. With
951/// no enabled profile the dashboard has no Sessions pane to press n in, so
952/// the first step is an agent to run (launch finding R13-3).
953fn setup_next_step(config: &Config) -> String {
954    if config.enabled_profiles().next().is_some() {
955        return "Run `mj` to open Mjolnir, then press n in the Sessions pane to start your first session."
956            .to_owned();
957    }
958    match config
959        .keybinds()
960        .labels(mj_core::config::KeyAction::OpenSettings)
961        .into_iter()
962        .next()
963    {
964        Some(key) => format!(
965            "Install a coding agent, then run `mj` and open Settings ({key}) to add its profile."
966        ),
967        None => "Install a coding agent, then run `mj` and open Settings from the command palette to add its profile.".to_owned(),
968    }
969}
970
971/// Setup only adds entries. Existing identifiers may belong to live sessions.
972fn reconcile_setup(
973    input: &mut impl SetupPrompter,
974    output: &mut impl Write,
975    existing: &Config,
976    discovered: Config,
977) -> Result<Config> {
978    let mut additions = existing.setup_additions(&discovered);
979    for id in additions.profiles.keys() {
980        writeln!(output, "Adding discovered profile {id}.")?;
981    }
982    for id in additions.bundles.keys() {
983        writeln!(output, "Adding repository bundle {id}.")?;
984    }
985    for (id, target) in &discovered.targets {
986        if !existing.targets.contains_key(id) {
987            continue;
988        }
989        let alternate = additions
990            .targets
991            .iter()
992            .find(|(_, added)| *added == target)
993            .map(|(id, _)| id.clone());
994        let Some(alternate) = alternate else { continue };
995        writeln!(
996            output,
997            "Target {id} already has different settings. Existing sessions will keep using it."
998        )?;
999        let answer = prompt(
1000            input,
1001            output,
1002            &format!("Keep {id}, or add the discovered settings as {alternate}? [K/a]: "),
1003        )?;
1004        if !matches!(answer.to_ascii_lowercase().as_str(), "a" | "add") {
1005            additions.targets.remove(&alternate);
1006            writeln!(
1007                output,
1008                "Keeping target {id}; discovered settings were not added."
1009            )?;
1010        }
1011    }
1012    Ok(additions)
1013}
1014
1015fn apply_setup_additions(latest: &mut Config, additions: &Config) -> Result<()> {
1016    fn add<T: Clone>(
1017        section: &str,
1018        latest: &mut BTreeMap<String, T>,
1019        additions: &BTreeMap<String, T>,
1020        same: impl Fn(&T, &T) -> bool,
1021    ) -> Result<()> {
1022        for (id, value) in additions {
1023            if latest.values().any(|existing| same(existing, value)) {
1024                continue;
1025            }
1026            ensure!(
1027                !latest.contains_key(id),
1028                "{section} {id:?} changed while setup was open; no configuration was written. Rerun mj setup to review the current settings"
1029            );
1030            latest.insert(id.clone(), value.clone());
1031        }
1032        Ok(())
1033    }
1034    add(
1035        "profile",
1036        &mut latest.profiles,
1037        &additions.profiles,
1038        HarnessProfile::same_installation,
1039    )?;
1040    add(
1041        "bundle",
1042        &mut latest.bundles,
1043        &additions.bundles,
1044        PartialEq::eq,
1045    )?;
1046    add(
1047        "target",
1048        &mut latest.targets,
1049        &additions.targets,
1050        PartialEq::eq,
1051    )?;
1052    latest.validate()
1053}
1054
1055fn write_discovered_homes(output: &mut impl Write, homes: &[DiscoveredHome]) -> Result<()> {
1056    writeln!(output, "Harness homes:")?;
1057    if homes.is_empty() {
1058        writeln!(
1059            output,
1060            "  No existing {} homes found.",
1061            HarnessKind::every_display_name_or()
1062        )?;
1063    }
1064    for home in homes {
1065        let authentication = if home.authenticated {
1066            "authenticated"
1067        } else {
1068            "not authenticated"
1069        };
1070        writeln!(
1071            output,
1072            "  {}: {} ({authentication}){}",
1073            home.kind.display_name(),
1074            home.path.display(),
1075            match home.kind.unsandboxed_guardian_warning() {
1076                Some(warning) => format!(" — {warning}"),
1077                None => String::new(),
1078            }
1079        )?;
1080    }
1081    Ok(())
1082}
1083
1084fn write_repository(output: &mut impl Write, repository: Option<&GithubRepository>) -> Result<()> {
1085    match repository {
1086        Some(repository) => writeln!(
1087            output,
1088            "GitHub origin: {} (a one-repository bundle will be created)",
1089            repository.source()
1090        )?,
1091        None => writeln!(
1092            output,
1093            "GitHub origin: none detected in the current directory."
1094        )?,
1095    }
1096    Ok(())
1097}
1098
1099fn write_runtimes(output: &mut impl Write, runtimes: &[RuntimeProbe]) -> Result<()> {
1100    writeln!(output, "Local runtimes:")?;
1101    for runtime in runtimes {
1102        let state = if runtime.usable {
1103            "usable"
1104        } else {
1105            "unavailable"
1106        };
1107        if runtime.detail.is_empty() {
1108            writeln!(output, "  {}: {state}", runtime.kind.label())?;
1109        } else {
1110            writeln!(
1111                output,
1112                "  {}: {state} ({})",
1113                runtime.kind.label(),
1114                runtime.detail
1115            )?;
1116        }
1117        if let Some(remediation) = &runtime.remediation {
1118            writeln!(output, "    remediation: {remediation}")?;
1119        }
1120    }
1121    Ok(())
1122}
1123
1124/// Offer an AWS EC2 target, but only when this host already has working AWS
1125/// credentials. Without them the step prints one line and asks nothing.
1126fn prompt_aws_target(
1127    input: &mut impl SetupPrompter,
1128    output: &mut impl Write,
1129    account: Option<&AwsAccount>,
1130) -> Result<Option<AwsTargetInput>> {
1131    let Some(account) = account else {
1132        writeln!(
1133            output,
1134            "AWS: no working `aws` CLI credentials found; skipping the AWS target."
1135        )?;
1136        return Ok(None);
1137    };
1138    writeln!(
1139        output,
1140        "AWS: credentials are valid for account {} ({}).",
1141        account.account, account.arn
1142    )?;
1143    let answer = prompt(input, output, "Add an AWS EC2 target? [y/N]: ")?;
1144    if !matches!(answer.to_ascii_lowercase().as_str(), "y" | "yes") {
1145        return Ok(None);
1146    }
1147
1148    let launch_template = prompt(input, output, "Launch template name: ")?;
1149    if launch_template.is_empty() {
1150        writeln!(
1151            output,
1152            "A launch template name is required; skipping the AWS target."
1153        )?;
1154        return Ok(None);
1155    }
1156
1157    let region_label = match &account.region {
1158        Some(region) => format!("Region [{region}]: "),
1159        None => "Region: ".to_owned(),
1160    };
1161    let region = prompt(input, output, &region_label)?;
1162    let region = if region.is_empty() {
1163        match &account.region {
1164            Some(region) => region.clone(),
1165            None => {
1166                writeln!(output, "A region is required; skipping the AWS target.")?;
1167                return Ok(None);
1168            }
1169        }
1170    } else {
1171        region
1172    };
1173
1174    let ssh_user = prompt(
1175        input,
1176        output,
1177        &format!("SSH user [{DEFAULT_AWS_SSH_USER}]: "),
1178    )?;
1179    let ssh_user = if ssh_user.is_empty() {
1180        DEFAULT_AWS_SSH_USER.to_owned()
1181    } else {
1182        ssh_user
1183    };
1184    let identity_file = prompt(input, output, "SSH identity file (optional): ")?;
1185
1186    Ok(Some(AwsTargetInput {
1187        launch_template,
1188        region,
1189        ssh_user,
1190        identity_file: (!identity_file.is_empty()).then(|| PathBuf::from(identity_file)),
1191    }))
1192}
1193
1194/// Offer an SSH target built from the aliases in `~/.ssh/config`.
1195///
1196/// With no aliases the step prints one line and asks nothing, the same way the
1197/// AWS step reports skipping.
1198fn prompt_ssh_target(
1199    input: &mut impl SetupPrompter,
1200    output: &mut impl Write,
1201    aliases: &[String],
1202    configured: &BTreeMap<String, TargetTemplate>,
1203) -> Result<Option<SshTargetInput>> {
1204    if aliases.is_empty() {
1205        writeln!(
1206            output,
1207            "SSH: no host aliases found in ~/.ssh/config; skipping the SSH target."
1208        )?;
1209        return Ok(None);
1210    }
1211    writeln!(output, "SSH: hosts found in ~/.ssh/config:")?;
1212    for (index, alias) in aliases.iter().enumerate() {
1213        writeln!(output, "  {}) {alias}", index + 1)?;
1214    }
1215    let answer = prompt(input, output, "Add an SSH target? [y/N]: ")?;
1216    if !matches!(answer.to_ascii_lowercase().as_str(), "y" | "yes") {
1217        return Ok(None);
1218    }
1219
1220    let choice = prompt(
1221        input,
1222        output,
1223        &format!("Host number 1-{} or a host name: ", aliases.len()),
1224    )?;
1225    let host = match choice.parse::<usize>() {
1226        Ok(index) if (1..=aliases.len()).contains(&index) => aliases[index - 1].clone(),
1227        _ if !choice.is_empty() => choice,
1228        _ => {
1229            writeln!(output, "A host is required; skipping the SSH target.")?;
1230            return Ok(None);
1231        }
1232    };
1233
1234    let kind = loop {
1235        let runtime = prompt(
1236            input,
1237            output,
1238            "Container runtime on that host, podman, docker, or bare [podman]: ",
1239        )?;
1240        let runtime = runtime.to_ascii_lowercase();
1241        if matches!(runtime.as_str(), "n" | "no" | "bare") {
1242            let permissions = loop {
1243                let mode = prompt(
1244                    input,
1245                    output,
1246                    "Raw-host permissions, guardian or yolo [guardian]: ",
1247                )?;
1248                match mode.to_ascii_lowercase().as_str() {
1249                    "" | "guardian" => break PermissionMode::Guardian,
1250                    "yolo" => break PermissionMode::Yolo,
1251                    _ => writeln!(output, "Permissions must be `guardian` or `yolo`.")?,
1252                }
1253            };
1254            break SshTargetKind::Bare { permissions };
1255        }
1256        let kind = if matches!(runtime.as_str(), "" | "y" | "yes" | "podman") {
1257            SshTargetKind::Podman {
1258                image: String::new(),
1259            }
1260        } else if runtime == "docker" {
1261            SshTargetKind::Docker {
1262                image: String::new(),
1263            }
1264        } else {
1265            writeln!(
1266                output,
1267                "Runtime must be `podman`, `docker`, or `bare`; please choose again."
1268            )?;
1269            continue;
1270        };
1271        let image = prompt(
1272            input,
1273            output,
1274            &format!("Container image [{DEFAULT_IMAGE}]: "),
1275        )?;
1276        let image = if image.is_empty() {
1277            DEFAULT_IMAGE.to_owned()
1278        } else {
1279            image
1280        };
1281        break match kind {
1282            SshTargetKind::Podman { .. } => SshTargetKind::Podman { image },
1283            SshTargetKind::Docker { .. } => SshTargetKind::Docker { image },
1284            SshTargetKind::Bare { .. } => unreachable!("bare runtime returned above"),
1285        };
1286    };
1287
1288    let Some(name) = prompt_ssh_target_name(input, output, &host, configured)? else {
1289        return Ok(None);
1290    };
1291
1292    Ok(Some(SshTargetInput { name, host, kind }))
1293}
1294
1295/// Ask for the SSH target's name until the answer is a usable target id.
1296///
1297/// Both failures are caught here rather than by `config.validate()` after every
1298/// question has been asked: an invalid id would otherwise discard the whole
1299/// dialog, and a name that is already taken would silently replace the target
1300/// it collides with.
1301fn prompt_ssh_target_name(
1302    input: &mut impl SetupPrompter,
1303    output: &mut impl Write,
1304    host: &str,
1305    configured: &BTreeMap<String, TargetTemplate>,
1306) -> Result<Option<String>> {
1307    loop {
1308        let Some(answer) = prompt_line(input, output, &format!("Target name [{host}]: "))? else {
1309            writeln!(output, "Input ended; skipping the SSH target.")?;
1310            return Ok(None);
1311        };
1312        let name = if answer.is_empty() {
1313            host.to_owned()
1314        } else {
1315            answer
1316        };
1317        if let Err(error) = validate_id("target", &name) {
1318            writeln!(output, "{error}")?;
1319            continue;
1320        }
1321        if configured.contains_key(&name) {
1322            writeln!(
1323                output,
1324                "Target {name} is already configured; choose another name."
1325            )?;
1326            continue;
1327        }
1328        return Ok(Some(name));
1329    }
1330}
1331
1332/// Phrase a failed setup smoke test the way `mj doctor --smoke` phrases the
1333/// same failure, so it joins the closing report instead of ending the run.
1334fn smoke_failure_check(runtime: RuntimeKind, image: &str, error: &anyhow::Error) -> DoctorCheck {
1335    let scope = match runtime {
1336        RuntimeKind::Docker => "Disposable run/exec/remove and OverlayFS attachment smoke test",
1337        RuntimeKind::Podman | RuntimeKind::AppleContainer => {
1338            "Disposable run/exec/remove smoke test"
1339        }
1340    };
1341    DoctorCheck::fixable(
1342        format!("runtime.{}.smoke", runtime.id()),
1343        format!("{} smoke test", runtime.label()),
1344        format!("{scope} failed for image {image}: {error:#}"),
1345        format!(
1346            "Fix the configured image or the {} runtime, then run `mj doctor --smoke` again.",
1347            runtime.label()
1348        ),
1349    )
1350}
1351
1352/// End setup with the same report `mj doctor` prints, so the user gets one
1353/// ready/fixable summary with remediations instead of two different signals.
1354///
1355/// `extra` carries anything setup itself learned that doctor cannot repeat
1356/// without the opt-in smoke test.
1357fn write_doctor_report(
1358    output: &mut impl Write,
1359    config_path: &Path,
1360    executor: &impl CommandExecutor,
1361    extra: Vec<DoctorCheck>,
1362) -> Result<()> {
1363    writeln!(output)?;
1364    writeln!(output, "Running `mj doctor` checks on the new config...")?;
1365    let mut checks = run_with_config_path(
1366        config_path,
1367        executor,
1368        current_apple_platform(executor),
1369        DoctorOptions { smoke: false },
1370    );
1371    checks.extend(extra);
1372    render_human(&checks, output)?;
1373    if all_ready(&checks) {
1374        writeln!(output, "Every check is ready.")?;
1375    } else {
1376        writeln!(
1377            output,
1378            "Apply the remediations above, then rerun `mj doctor`."
1379        )?;
1380    }
1381    Ok(())
1382}
1383
1384fn prompt(input: &mut impl SetupPrompter, output: &mut impl Write, label: &str) -> Result<String> {
1385    Ok(prompt_line(input, output, label)?.unwrap_or_default())
1386}
1387
1388/// Read one answer, reporting `None` once the input has ended.
1389///
1390/// Every question but one treats the end of input as an empty answer and takes
1391/// its default. A question that must be asked again until it is answered needs
1392/// the difference, or it would loop forever against a closed stdin.
1393fn prompt_line(
1394    input: &mut impl SetupPrompter,
1395    output: &mut impl Write,
1396    label: &str,
1397) -> Result<Option<String>> {
1398    input.read_prompt(output, label)
1399}
1400
1401trait SetupPrompter {
1402    fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>>;
1403}
1404
1405impl<R: BufRead> SetupPrompter for R {
1406    fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>> {
1407        write!(output, "{label}")?;
1408        output.flush()?;
1409        let mut answer = String::new();
1410        let read = self.read_line(&mut answer).context("read setup response")?;
1411        Ok((read > 0).then(|| answer.trim().to_owned()))
1412    }
1413}
1414
1415#[derive(Default)]
1416struct ReadlinePrompter(crate::readline::LineReader);
1417
1418impl SetupPrompter for ReadlinePrompter {
1419    fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>> {
1420        output.flush()?;
1421        self.0.read_line(label).context("read setup response")
1422    }
1423}
1424
1425fn write_summary(
1426    output: &mut impl Write,
1427    config_path: &Path,
1428    config: &Config,
1429    runtimes: &[(RuntimeKind, String)],
1430) -> Result<()> {
1431    writeln!(output, "Mjolnir will add to {}:", config_path.display())?;
1432    let counted = mj_core::text::counted;
1433    writeln!(
1434        output,
1435        "  {}",
1436        counted(config.profiles.len(), "profile", "profiles")
1437    )?;
1438    writeln!(
1439        output,
1440        "  {}",
1441        counted(config.bundles.len(), "bundle", "bundles")
1442    )?;
1443    if config
1444        .targets
1445        .values()
1446        .any(|target| matches!(target, TargetTemplate::LocalBare))
1447    {
1448        writeln!(
1449            output,
1450            "  localhost target; each session runs from a staged copy of its profile home"
1451        )?;
1452    }
1453    for (runtime, image) in runtimes {
1454        writeln!(output, "  {} target using {image}", runtime.label())?;
1455    }
1456    for id in config.targets.keys() {
1457        writeln!(output, "  target id: {id}")?;
1458    }
1459    if let Some(TargetTemplate::AwsEc2 {
1460        launch_template,
1461        region,
1462        ..
1463    }) = config.targets.get(AWS_TARGET_ID)
1464    {
1465        writeln!(
1466            output,
1467            "  AWS EC2 target using launch template {launch_template} in {region}"
1468        )?;
1469    }
1470    for (id, target) in &config.targets {
1471        match target {
1472            TargetTemplate::SshBare { ssh, .. } => {
1473                writeln!(output, "  SSH target {id} on {} (no container)", ssh.host)?;
1474            }
1475            TargetTemplate::SshPodman { ssh, container, .. } => {
1476                writeln!(
1477                    output,
1478                    "  SSH target {id} on {} using Podman image {}",
1479                    ssh.host, container.image
1480                )?;
1481            }
1482            TargetTemplate::SshDocker { ssh, container } => {
1483                writeln!(
1484                    output,
1485                    "  SSH target {id} on {} using Docker image {}",
1486                    ssh.host, container.image
1487                )?;
1488            }
1489            _ => {}
1490        }
1491    }
1492    if config_path.exists() {
1493        writeln!(
1494            output,
1495            "  Existing profiles, bundles, targets, and preferences will be preserved."
1496        )?;
1497    }
1498    Ok(())
1499}
1500
1501fn smoke_target(runtime: RuntimeKind, image: &str) -> RuntimeTargetTemplate {
1502    let container = RuntimeContainerTemplate {
1503        build_cache: None,
1504        image: image.to_owned(),
1505        pull_policy: Default::default(),
1506        extra_run_args: vec![],
1507        workspace_storage: Default::default(),
1508    };
1509    match runtime {
1510        RuntimeKind::Podman => RuntimeTargetTemplate::LocalPodman(container),
1511        RuntimeKind::Docker => RuntimeTargetTemplate::LocalDocker(container),
1512        RuntimeKind::AppleContainer => RuntimeTargetTemplate::AppleContainer(container),
1513    }
1514}
1515
1516fn run_smoke_test(
1517    output: &mut impl Write,
1518    target: &RuntimeTargetTemplate,
1519    executor: &impl CommandExecutor,
1520) -> Result<()> {
1521    let smoke_id = format!(
1522        "setup-{}-{:x}",
1523        std::process::id(),
1524        SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos()
1525    );
1526    let description = match target {
1527        RuntimeTargetTemplate::LocalDocker(_) => {
1528            "Smoke test: verifying a disposable container and writable OverlayFS attachment..."
1529        }
1530        _ => "Smoke test: verifying a disposable container...",
1531    };
1532    writeln!(output, "{description}")?;
1533    if let Some(announcement) = smoke_download_announcement(target) {
1534        writeln!(output, "{announcement}")?;
1535    }
1536    // Nothing is printed while the test runs, so close the wait with its
1537    // outcome and how long it took (launch finding R3-10).
1538    let started = std::time::Instant::now();
1539    let result = run_setup_smoke_test(target, &smoke_id, executor);
1540    let took = mj_core::activity::describe_duration(
1541        u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
1542    );
1543    match &result {
1544        Ok(()) => writeln!(output, "Smoke test passed in {took}.")?,
1545        Err(_) => writeln!(
1546            output,
1547            "Smoke test failed after {took}; the checks below say what to fix."
1548        )?,
1549    }
1550    result
1551}
1552
1553/// Download size of [`mj_core::config::DEFAULT_CONTAINER_IMAGE`], as the
1554/// launch campaign measured it (1.93 GB on 2026-09-23). Update it when the
1555/// image grows or shrinks noticeably.
1556const DEFAULT_IMAGE_DOWNLOAD_SIZE: &str = "about 2 GB";
1557
1558/// The engine pulls a missing image as part of the smoke test's first
1559/// command and prints nothing while it does, so say what may happen before
1560/// the wait starts.
1561fn smoke_download_announcement(target: &RuntimeTargetTemplate) -> Option<String> {
1562    let (engine, container) = match target {
1563        RuntimeTargetTemplate::LocalPodman(container) => ("Podman", container),
1564        RuntimeTargetTemplate::LocalDocker(container) => ("Docker", container),
1565        RuntimeTargetTemplate::AppleContainer(container) => ("Apple container", container),
1566        _ => return None,
1567    };
1568    let image = &container.image;
1569    let size = if image == mj_core::config::DEFAULT_CONTAINER_IMAGE {
1570        format!(" ({DEFAULT_IMAGE_DOWNLOAD_SIZE})")
1571    } else {
1572        String::new()
1573    };
1574    Some(format!(
1575        "If {image} is not on this machine yet, {engine} downloads it first{size}. That can take several minutes, and nothing more is printed until it finishes."
1576    ))
1577}
1578
1579#[cfg(test)]
1580mod tests;