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