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/// The same default `serde` applies to a hand-written SSH machine.
770fn default_ssh_workspace_prefix() -> PathBuf {
771    PathBuf::from(".local/share/hel/workspaces")
772}
773
774fn config_id(value: &str) -> String {
775    let mut id = value
776        .chars()
777        .filter(|character| {
778            character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
779        })
780        .take(64)
781        .collect::<String>();
782    if id.is_empty() || matches!(id.as_str(), "." | "..") {
783        id = "repository".to_owned();
784    }
785    id
786}
787
788/// Ask the setup questions, write the configuration, and report on it.
789///
790/// The smoke test and the closing doctor report run through different
791/// executors on purpose: a smoke test may pull a multi-gigabyte image and must
792/// not be given a deadline, while every prerequisite probe must answer quickly
793/// or be reported as a fixable check.
794pub fn run_setup_dialog_with(
795    input: &mut impl BufRead,
796    output: &mut impl Write,
797    config_path: &Path,
798    discovery: &SetupDiscovery,
799    smoke_executor: &impl CommandExecutor,
800    probe_executor: &impl CommandExecutor,
801) -> Result<SetupOutcome> {
802    run_setup_dialog_inner(
803        input,
804        output,
805        config_path,
806        discovery,
807        smoke_executor,
808        probe_executor,
809    )
810}
811
812fn run_setup_dialog_inner(
813    input: &mut impl SetupPrompter,
814    output: &mut impl Write,
815    config_path: &Path,
816    discovery: &SetupDiscovery,
817    smoke_executor: &impl CommandExecutor,
818    probe_executor: &impl CommandExecutor,
819) -> Result<SetupOutcome> {
820    let existing = Config::load_from(config_path)?;
821    writeln!(output, "Welcome to Mjolnir setup.")?;
822    writeln!(output)?;
823    write_discovered_homes(output, &discovery.homes)?;
824    write_repository(output, discovery.repository.as_ref())?;
825    write_runtimes(output, &discovery.runtimes)?;
826
827    let runtimes = if discovery.runtimes.iter().any(|runtime| runtime.usable) {
828        let image = prompt(
829            input,
830            output,
831            &format!("Container image [{DEFAULT_IMAGE}]: "),
832        )?;
833        let image = if image.is_empty() {
834            DEFAULT_IMAGE.to_owned()
835        } else {
836            image
837        };
838        discovery
839            .runtimes
840            .iter()
841            .filter(|runtime| runtime.usable)
842            .map(|runtime| (runtime.kind, image.clone()))
843            .collect::<Vec<_>>()
844    } else {
845        writeln!(
846            output,
847            "No usable container runtime found; raw localhost will still be configured."
848        )?;
849        Vec::new()
850    };
851    let aws = prompt_aws_target(input, output, discovery.aws.as_ref())?;
852    let runtime_choices = runtimes
853        .iter()
854        .map(|(runtime, image)| (*runtime, image.as_str()))
855        .collect::<Vec<_>>();
856    // Build what the earlier answers already claimed, so the SSH step can
857    // refuse a target name that would replace one of them.
858    let configured = build_config_with_runtimes(
859        &discovery.homes,
860        discovery.repository.as_ref(),
861        &runtime_choices,
862        aws.as_ref(),
863        None,
864    );
865    let ssh = prompt_ssh_target(input, output, &discovery.ssh_hosts, &configured.targets)?;
866    let config = build_config_with_runtimes(
867        &discovery.homes,
868        discovery.repository.as_ref(),
869        &runtime_choices,
870        aws.as_ref(),
871        ssh.as_ref(),
872    );
873    config.validate()?;
874    let additions = reconcile_setup(input, output, &existing, config)?;
875    let runtimes = runtimes
876        .into_iter()
877        .filter(|(runtime, image)| {
878            let (_, target) = local_runtime_target(*runtime, image);
879            additions.targets.values().any(|added| added == &target)
880        })
881        .collect::<Vec<_>>();
882
883    writeln!(output)?;
884    write_summary(output, config_path, &additions, &runtimes)?;
885    let confirmation = prompt(input, output, "Write this configuration? [y/N]: ")?;
886    if !matches!(confirmation.to_ascii_lowercase().as_str(), "y" | "yes") {
887        writeln!(output, "Setup cancelled.")?;
888        return Ok(SetupOutcome::Cancelled);
889    }
890
891    writeln!(output, "Writing {}...", config_path.display())?;
892    Config::update_to(config_path, |latest| {
893        apply_setup_additions(latest, &additions)
894    })?;
895    // A failed smoke test is a fixable prerequisite, not a reason to abandon
896    // the run: the configuration is already written, and this is exactly when
897    // the closing report's remediations matter most.
898    let smoke_failures = runtimes
899        .iter()
900        .filter_map(|(runtime, image)| {
901            let target = smoke_target(*runtime, image);
902            run_smoke_test(output, &target, smoke_executor)
903                .err()
904                .map(|error| smoke_failure_check(*runtime, image, &error))
905        })
906        .collect();
907    write_doctor_report(output, config_path, probe_executor, smoke_failures)?;
908    writeln!(
909        output,
910        "Advanced users can edit TOML for extra profiles, virtual monorepos, SSH, and AWS."
911    )?;
912    writeln!(output, "Press n to start your first session.")?;
913    Ok(SetupOutcome::Written)
914}
915
916/// Setup only adds entries. Existing identifiers may belong to live sessions.
917fn reconcile_setup(
918    input: &mut impl SetupPrompter,
919    output: &mut impl Write,
920    existing: &Config,
921    discovered: Config,
922) -> Result<Config> {
923    let mut additions = existing.setup_additions(&discovered);
924    for id in additions.profiles.keys() {
925        writeln!(output, "Adding discovered profile {id}.")?;
926    }
927    for id in additions.bundles.keys() {
928        writeln!(output, "Adding repository bundle {id}.")?;
929    }
930    for (id, target) in &discovered.targets {
931        if !existing.targets.contains_key(id) {
932            continue;
933        }
934        let alternate = additions
935            .targets
936            .iter()
937            .find(|(_, added)| *added == target)
938            .map(|(id, _)| id.clone());
939        let Some(alternate) = alternate else { continue };
940        writeln!(
941            output,
942            "Target {id} already has different settings. Existing sessions will keep using it."
943        )?;
944        let answer = prompt(
945            input,
946            output,
947            &format!("Keep {id}, or add the discovered settings as {alternate}? [K/a]: "),
948        )?;
949        if !matches!(answer.to_ascii_lowercase().as_str(), "a" | "add") {
950            additions.targets.remove(&alternate);
951            writeln!(
952                output,
953                "Keeping target {id}; discovered settings were not added."
954            )?;
955        }
956    }
957    Ok(additions)
958}
959
960fn apply_setup_additions(latest: &mut Config, additions: &Config) -> Result<()> {
961    fn add<T: Clone>(
962        section: &str,
963        latest: &mut BTreeMap<String, T>,
964        additions: &BTreeMap<String, T>,
965        same: impl Fn(&T, &T) -> bool,
966    ) -> Result<()> {
967        for (id, value) in additions {
968            if latest.values().any(|existing| same(existing, value)) {
969                continue;
970            }
971            ensure!(
972                !latest.contains_key(id),
973                "{section} {id:?} changed while setup was open; no configuration was written. Rerun mj setup to review the current settings"
974            );
975            latest.insert(id.clone(), value.clone());
976        }
977        Ok(())
978    }
979    add(
980        "profile",
981        &mut latest.profiles,
982        &additions.profiles,
983        HarnessProfile::same_installation,
984    )?;
985    add(
986        "bundle",
987        &mut latest.bundles,
988        &additions.bundles,
989        PartialEq::eq,
990    )?;
991    add(
992        "target",
993        &mut latest.targets,
994        &additions.targets,
995        PartialEq::eq,
996    )?;
997    latest.validate()
998}
999
1000fn write_discovered_homes(output: &mut impl Write, homes: &[DiscoveredHome]) -> Result<()> {
1001    writeln!(output, "Harness homes:")?;
1002    if homes.is_empty() {
1003        writeln!(
1004            output,
1005            "  No existing Codex, Claude Code, Kimi Code, or Grok Build homes found."
1006        )?;
1007    }
1008    for home in homes {
1009        let authentication = if home.authenticated {
1010            "authenticated"
1011        } else {
1012            "not authenticated"
1013        };
1014        writeln!(
1015            output,
1016            "  {}: {} ({authentication}){}",
1017            home.kind.display_name(),
1018            home.path.display(),
1019            match home.kind.unsandboxed_guardian_warning() {
1020                Some(warning) => format!(" — {warning}"),
1021                None => String::new(),
1022            }
1023        )?;
1024    }
1025    Ok(())
1026}
1027
1028fn write_repository(output: &mut impl Write, repository: Option<&GithubRepository>) -> Result<()> {
1029    match repository {
1030        Some(repository) => writeln!(
1031            output,
1032            "GitHub origin: {} (a one-repository bundle will be created)",
1033            repository.source()
1034        )?,
1035        None => writeln!(
1036            output,
1037            "GitHub origin: none detected in the current directory."
1038        )?,
1039    }
1040    Ok(())
1041}
1042
1043fn write_runtimes(output: &mut impl Write, runtimes: &[RuntimeProbe]) -> Result<()> {
1044    writeln!(output, "Local runtimes:")?;
1045    for runtime in runtimes {
1046        let state = if runtime.usable {
1047            "usable"
1048        } else {
1049            "unavailable"
1050        };
1051        if runtime.detail.is_empty() {
1052            writeln!(output, "  {}: {state}", runtime.kind.label())?;
1053        } else {
1054            writeln!(
1055                output,
1056                "  {}: {state} ({})",
1057                runtime.kind.label(),
1058                runtime.detail
1059            )?;
1060        }
1061        if let Some(remediation) = &runtime.remediation {
1062            writeln!(output, "    remediation: {remediation}")?;
1063        }
1064    }
1065    Ok(())
1066}
1067
1068/// Offer an AWS EC2 target, but only when this host already has working AWS
1069/// credentials. Without them the step prints one line and asks nothing.
1070fn prompt_aws_target(
1071    input: &mut impl SetupPrompter,
1072    output: &mut impl Write,
1073    account: Option<&AwsAccount>,
1074) -> Result<Option<AwsTargetInput>> {
1075    let Some(account) = account else {
1076        writeln!(
1077            output,
1078            "AWS: no working `aws` CLI credentials found; skipping the AWS target."
1079        )?;
1080        return Ok(None);
1081    };
1082    writeln!(
1083        output,
1084        "AWS: credentials are valid for account {} ({}).",
1085        account.account, account.arn
1086    )?;
1087    let answer = prompt(input, output, "Add an AWS EC2 target? [y/N]: ")?;
1088    if !matches!(answer.to_ascii_lowercase().as_str(), "y" | "yes") {
1089        return Ok(None);
1090    }
1091
1092    let launch_template = prompt(input, output, "Launch template name: ")?;
1093    if launch_template.is_empty() {
1094        writeln!(
1095            output,
1096            "A launch template name is required; skipping the AWS target."
1097        )?;
1098        return Ok(None);
1099    }
1100
1101    let region_label = match &account.region {
1102        Some(region) => format!("Region [{region}]: "),
1103        None => "Region: ".to_owned(),
1104    };
1105    let region = prompt(input, output, &region_label)?;
1106    let region = if region.is_empty() {
1107        match &account.region {
1108            Some(region) => region.clone(),
1109            None => {
1110                writeln!(output, "A region is required; skipping the AWS target.")?;
1111                return Ok(None);
1112            }
1113        }
1114    } else {
1115        region
1116    };
1117
1118    let ssh_user = prompt(
1119        input,
1120        output,
1121        &format!("SSH user [{DEFAULT_AWS_SSH_USER}]: "),
1122    )?;
1123    let ssh_user = if ssh_user.is_empty() {
1124        DEFAULT_AWS_SSH_USER.to_owned()
1125    } else {
1126        ssh_user
1127    };
1128    let identity_file = prompt(input, output, "SSH identity file (optional): ")?;
1129
1130    Ok(Some(AwsTargetInput {
1131        launch_template,
1132        region,
1133        ssh_user,
1134        identity_file: (!identity_file.is_empty()).then(|| PathBuf::from(identity_file)),
1135    }))
1136}
1137
1138/// Offer an SSH target built from the aliases in `~/.ssh/config`.
1139///
1140/// With no aliases the step prints one line and asks nothing, the same way the
1141/// AWS step reports skipping.
1142fn prompt_ssh_target(
1143    input: &mut impl SetupPrompter,
1144    output: &mut impl Write,
1145    aliases: &[String],
1146    configured: &BTreeMap<String, TargetTemplate>,
1147) -> Result<Option<SshTargetInput>> {
1148    if aliases.is_empty() {
1149        writeln!(
1150            output,
1151            "SSH: no host aliases found in ~/.ssh/config; skipping the SSH target."
1152        )?;
1153        return Ok(None);
1154    }
1155    writeln!(output, "SSH: hosts found in ~/.ssh/config:")?;
1156    for (index, alias) in aliases.iter().enumerate() {
1157        writeln!(output, "  {}) {alias}", index + 1)?;
1158    }
1159    let answer = prompt(input, output, "Add an SSH target? [y/N]: ")?;
1160    if !matches!(answer.to_ascii_lowercase().as_str(), "y" | "yes") {
1161        return Ok(None);
1162    }
1163
1164    let choice = prompt(
1165        input,
1166        output,
1167        &format!("Host number 1-{} or a host name: ", aliases.len()),
1168    )?;
1169    let host = match choice.parse::<usize>() {
1170        Ok(index) if (1..=aliases.len()).contains(&index) => aliases[index - 1].clone(),
1171        _ if !choice.is_empty() => choice,
1172        _ => {
1173            writeln!(output, "A host is required; skipping the SSH target.")?;
1174            return Ok(None);
1175        }
1176    };
1177
1178    let kind = loop {
1179        let runtime = prompt(
1180            input,
1181            output,
1182            "Container runtime on that host, podman, docker, or bare [podman]: ",
1183        )?;
1184        let runtime = runtime.to_ascii_lowercase();
1185        if matches!(runtime.as_str(), "n" | "no" | "bare") {
1186            let permissions = loop {
1187                let mode = prompt(
1188                    input,
1189                    output,
1190                    "Raw-host permissions, guardian or yolo [guardian]: ",
1191                )?;
1192                match mode.to_ascii_lowercase().as_str() {
1193                    "" | "guardian" => break PermissionMode::Guardian,
1194                    "yolo" => break PermissionMode::Yolo,
1195                    _ => writeln!(output, "Permissions must be `guardian` or `yolo`.")?,
1196                }
1197            };
1198            break SshTargetKind::Bare { permissions };
1199        }
1200        let kind = if matches!(runtime.as_str(), "" | "y" | "yes" | "podman") {
1201            SshTargetKind::Podman {
1202                image: String::new(),
1203            }
1204        } else if runtime == "docker" {
1205            SshTargetKind::Docker {
1206                image: String::new(),
1207            }
1208        } else {
1209            writeln!(
1210                output,
1211                "Runtime must be `podman`, `docker`, or `bare`; please choose again."
1212            )?;
1213            continue;
1214        };
1215        let image = prompt(
1216            input,
1217            output,
1218            &format!("Container image [{DEFAULT_IMAGE}]: "),
1219        )?;
1220        let image = if image.is_empty() {
1221            DEFAULT_IMAGE.to_owned()
1222        } else {
1223            image
1224        };
1225        break match kind {
1226            SshTargetKind::Podman { .. } => SshTargetKind::Podman { image },
1227            SshTargetKind::Docker { .. } => SshTargetKind::Docker { image },
1228            SshTargetKind::Bare { .. } => unreachable!("bare runtime returned above"),
1229        };
1230    };
1231
1232    let Some(name) = prompt_ssh_target_name(input, output, &host, configured)? else {
1233        return Ok(None);
1234    };
1235
1236    Ok(Some(SshTargetInput { name, host, kind }))
1237}
1238
1239/// Ask for the SSH target's name until the answer is a usable target id.
1240///
1241/// Both failures are caught here rather than by `config.validate()` after every
1242/// question has been asked: an invalid id would otherwise discard the whole
1243/// dialog, and a name that is already taken would silently replace the target
1244/// it collides with.
1245fn prompt_ssh_target_name(
1246    input: &mut impl SetupPrompter,
1247    output: &mut impl Write,
1248    host: &str,
1249    configured: &BTreeMap<String, TargetTemplate>,
1250) -> Result<Option<String>> {
1251    loop {
1252        let Some(answer) = prompt_line(input, output, &format!("Target name [{host}]: "))? else {
1253            writeln!(output, "Input ended; skipping the SSH target.")?;
1254            return Ok(None);
1255        };
1256        let name = if answer.is_empty() {
1257            host.to_owned()
1258        } else {
1259            answer
1260        };
1261        if let Err(error) = validate_id("target", &name) {
1262            writeln!(output, "{error}")?;
1263            continue;
1264        }
1265        if configured.contains_key(&name) {
1266            writeln!(
1267                output,
1268                "Target {name} is already configured; choose another name."
1269            )?;
1270            continue;
1271        }
1272        return Ok(Some(name));
1273    }
1274}
1275
1276/// Phrase a failed setup smoke test the way `mj doctor --smoke` phrases the
1277/// same failure, so it joins the closing report instead of ending the run.
1278fn smoke_failure_check(runtime: RuntimeKind, image: &str, error: &anyhow::Error) -> DoctorCheck {
1279    let scope = match runtime {
1280        RuntimeKind::Docker => "Disposable run/exec/remove and OverlayFS attachment smoke test",
1281        RuntimeKind::Podman | RuntimeKind::AppleContainer => {
1282            "Disposable run/exec/remove smoke test"
1283        }
1284    };
1285    DoctorCheck::fixable(
1286        format!("runtime.{}.smoke", runtime.id()),
1287        format!("{} smoke test", runtime.label()),
1288        format!("{scope} failed for image {image}: {error:#}"),
1289        format!(
1290            "Fix the configured image or the {} runtime, then run `mj doctor --smoke` again.",
1291            runtime.label()
1292        ),
1293    )
1294}
1295
1296/// End setup with the same report `mj doctor` prints, so the user gets one
1297/// ready/fixable summary with remediations instead of two different signals.
1298///
1299/// `extra` carries anything setup itself learned that doctor cannot repeat
1300/// without the opt-in smoke test.
1301fn write_doctor_report(
1302    output: &mut impl Write,
1303    config_path: &Path,
1304    executor: &impl CommandExecutor,
1305    extra: Vec<DoctorCheck>,
1306) -> Result<()> {
1307    writeln!(output)?;
1308    writeln!(output, "Running `mj doctor` checks on the new config...")?;
1309    let mut checks = run_with_config_path(
1310        config_path,
1311        executor,
1312        current_apple_platform(executor),
1313        DoctorOptions { smoke: false },
1314    );
1315    checks.extend(extra);
1316    render_human(&checks, output)?;
1317    if all_ready(&checks) {
1318        writeln!(output, "Every check is ready.")?;
1319    } else {
1320        writeln!(
1321            output,
1322            "Apply the remediations above, then rerun `mj doctor`."
1323        )?;
1324    }
1325    Ok(())
1326}
1327
1328fn prompt(input: &mut impl SetupPrompter, output: &mut impl Write, label: &str) -> Result<String> {
1329    Ok(prompt_line(input, output, label)?.unwrap_or_default())
1330}
1331
1332/// Read one answer, reporting `None` once the input has ended.
1333///
1334/// Every question but one treats the end of input as an empty answer and takes
1335/// its default. A question that must be asked again until it is answered needs
1336/// the difference, or it would loop forever against a closed stdin.
1337fn prompt_line(
1338    input: &mut impl SetupPrompter,
1339    output: &mut impl Write,
1340    label: &str,
1341) -> Result<Option<String>> {
1342    input.read_prompt(output, label)
1343}
1344
1345trait SetupPrompter {
1346    fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>>;
1347}
1348
1349impl<R: BufRead> SetupPrompter for R {
1350    fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>> {
1351        write!(output, "{label}")?;
1352        output.flush()?;
1353        let mut answer = String::new();
1354        let read = self.read_line(&mut answer).context("read setup response")?;
1355        Ok((read > 0).then(|| answer.trim().to_owned()))
1356    }
1357}
1358
1359#[derive(Default)]
1360struct ReadlinePrompter(crate::readline::LineReader);
1361
1362impl SetupPrompter for ReadlinePrompter {
1363    fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>> {
1364        output.flush()?;
1365        self.0.read_line(label).context("read setup response")
1366    }
1367}
1368
1369fn write_summary(
1370    output: &mut impl Write,
1371    config_path: &Path,
1372    config: &Config,
1373    runtimes: &[(RuntimeKind, String)],
1374) -> Result<()> {
1375    writeln!(output, "Mjolnir will add to {}:", config_path.display())?;
1376    writeln!(output, "  {} profile(s)", config.profiles.len())?;
1377    writeln!(output, "  {} bundle(s)", config.bundles.len())?;
1378    if config
1379        .targets
1380        .values()
1381        .any(|target| matches!(target, TargetTemplate::LocalBare))
1382    {
1383        writeln!(
1384            output,
1385            "  raw localhost target using configured harness homes directly"
1386        )?;
1387    }
1388    for (runtime, image) in runtimes {
1389        writeln!(output, "  {} target using {image}", runtime.label())?;
1390    }
1391    for id in config.targets.keys() {
1392        writeln!(output, "  target id: {id}")?;
1393    }
1394    if let Some(TargetTemplate::AwsEc2 {
1395        launch_template,
1396        region,
1397        ..
1398    }) = config.targets.get(AWS_TARGET_ID)
1399    {
1400        writeln!(
1401            output,
1402            "  AWS EC2 target using launch template {launch_template} in {region}"
1403        )?;
1404    }
1405    for (id, target) in &config.targets {
1406        match target {
1407            TargetTemplate::SshBare { ssh, .. } => {
1408                writeln!(output, "  SSH target {id} on {} (no container)", ssh.host)?;
1409            }
1410            TargetTemplate::SshPodman { ssh, container, .. } => {
1411                writeln!(
1412                    output,
1413                    "  SSH target {id} on {} using Podman image {}",
1414                    ssh.host, container.image
1415                )?;
1416            }
1417            TargetTemplate::SshDocker { ssh, container } => {
1418                writeln!(
1419                    output,
1420                    "  SSH target {id} on {} using Docker image {}",
1421                    ssh.host, container.image
1422                )?;
1423            }
1424            _ => {}
1425        }
1426    }
1427    if config_path.exists() {
1428        writeln!(
1429            output,
1430            "  Existing profiles, bundles, targets, and preferences will be preserved."
1431        )?;
1432    }
1433    Ok(())
1434}
1435
1436fn smoke_target(runtime: RuntimeKind, image: &str) -> RuntimeTargetTemplate {
1437    let container = RuntimeContainerTemplate {
1438        build_cache: None,
1439        image: image.to_owned(),
1440        pull_policy: Default::default(),
1441        extra_run_args: vec![],
1442        workspace_storage: Default::default(),
1443    };
1444    match runtime {
1445        RuntimeKind::Podman => RuntimeTargetTemplate::LocalPodman(container),
1446        RuntimeKind::Docker => RuntimeTargetTemplate::LocalDocker(container),
1447        RuntimeKind::AppleContainer => RuntimeTargetTemplate::AppleContainer(container),
1448    }
1449}
1450
1451fn run_smoke_test(
1452    output: &mut impl Write,
1453    target: &RuntimeTargetTemplate,
1454    executor: &impl CommandExecutor,
1455) -> Result<()> {
1456    let smoke_id = format!(
1457        "setup-{}-{:x}",
1458        std::process::id(),
1459        SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos()
1460    );
1461    let description = match target {
1462        RuntimeTargetTemplate::LocalDocker(_) => {
1463            "Smoke test: verifying a disposable container and writable OverlayFS attachment..."
1464        }
1465        _ => "Smoke test: verifying a disposable container...",
1466    };
1467    writeln!(output, "{description}")?;
1468    run_setup_smoke_test(target, &smoke_id, executor)
1469}
1470
1471#[cfg(test)]
1472mod tests;