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