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::harness_authentication_marker;
21use mj_core::config::{
22    AwsAddressSource, Config, ContainerTemplate, HarnessKind, HarnessProfile, PermissionMode,
23    ProjectBundle, ProjectRepository, SshConnection, TargetTemplate, unique_config_id as unique_id,
24    validate_id,
25};
26
27/// AWS credential detection must never stall an interactive first run, so the
28/// probe commands share a bounded deadline.
29const AWS_PROBE_TIMEOUT: Duration = Duration::from_secs(8);
30
31/// The user every Hel launch template image boots with; see
32/// scripts/update-runson-launch-template.sh.
33const DEFAULT_AWS_SSH_USER: &str = "ubuntu";
34const AWS_TARGET_ID: &str = "aws";
35
36// Published from containers/Containerfile.agent-dev by
37// .github/workflows/publish-agent-dev-image.yml. It already carries Node, Rust,
38// Git, gh, and the pinned ACP bridges, so a first session does not have to
39// install them.
40pub use mj_client::target::DEFAULT_IMAGE;
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct DiscoveredHome {
44    pub kind: HarnessKind,
45    pub path: PathBuf,
46    pub authenticated: bool,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct GithubRepository {
51    pub owner: String,
52    pub repository: String,
53}
54
55impl GithubRepository {
56    fn source(&self) -> String {
57        format!("{}/{}", self.owner, self.repository)
58    }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum RuntimeKind {
63    Podman,
64    Docker,
65    AppleContainer,
66}
67
68impl RuntimeKind {
69    fn id(self) -> &'static str {
70        match self {
71            Self::Podman => "podman",
72            Self::Docker => "docker",
73            Self::AppleContainer => "apple-container",
74        }
75    }
76
77    fn label(self) -> &'static str {
78        match self {
79            Self::Podman => "Podman",
80            Self::Docker => "Docker",
81            Self::AppleContainer => "Apple container",
82        }
83    }
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct RuntimeProbe {
88    pub kind: RuntimeKind,
89    pub usable: bool,
90    pub detail: String,
91    /// The fix `mj doctor` would print for this runtime, carried through so
92    /// setup never invents its own remediation wording.
93    pub remediation: Option<String>,
94}
95
96/// An AWS identity that `aws sts get-caller-identity` confirmed.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct AwsAccount {
99    pub account: String,
100    pub arn: String,
101    /// The CLI's configured default region, when it has one.
102    pub region: Option<String>,
103}
104
105/// The answers that become a `[targets.aws]` entry.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct AwsTargetInput {
108    pub launch_template: String,
109    pub region: String,
110    pub ssh_user: String,
111    pub identity_file: Option<PathBuf>,
112}
113
114/// Which kind of SSH target the user chose in the SSH step.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub enum SshTargetKind {
117    Bare { permissions: PermissionMode },
118    Podman { image: String },
119    Docker { image: String },
120}
121
122/// The answers that become a `[targets.<name>]` SSH entry.
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct SshTargetInput {
125    pub name: String,
126    pub host: String,
127    pub kind: SshTargetKind,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct SetupDiscovery {
132    pub homes: Vec<DiscoveredHome>,
133    pub repository: Option<GithubRepository>,
134    pub runtimes: Vec<RuntimeProbe>,
135    /// `None` when this host has no working AWS CLI credentials, in which case
136    /// setup never offers an AWS target.
137    pub aws: Option<AwsAccount>,
138    /// Concrete `Host` aliases read from `~/.ssh/config`; empty when the file
139    /// is absent or only defines wildcard blocks.
140    pub ssh_hosts: Vec<String>,
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum SetupOutcome {
145    Written,
146    Cancelled,
147}
148
149/// Give an unconfigured terminal installation a local Codex profile and target
150/// without making remote/container setup a prerequisite for explicit session
151/// creation. This only writes configuration; it never creates a session.
152pub fn initialize_local_startup_config(config_path: &Path) -> Result<()> {
153    #[cfg(unix)]
154    {
155        let config = Config::load_from(config_path)?;
156        if config.is_unconfigured() && config.newer_config_version.is_none() {
157            let kind = HarnessKind::Codex;
158            let home = std::env::var_os(kind.home_env())
159                .map(|value| kind.home_from_environment(value))
160                .or_else(|| dirs::home_dir().map(|home| home.join(kind.default_home_leaf())))
161                .context("locate Codex home for the default local profile")?;
162            let home = std::path::absolute(home).context("resolve Codex profile home")?;
163            Config::update_to(config_path, |fresh| {
164                if fresh.is_unconfigured() {
165                    configure_local_startup(fresh, home);
166                }
167                Ok(())
168            })?;
169        }
170    }
171    // Local bare targets are unsupported on Windows; retain explicit setup.
172    #[cfg(not(unix))]
173    let _ = config_path;
174    Ok(())
175}
176
177#[cfg(unix)]
178fn configure_local_startup(config: &mut Config, codex_home: PathBuf) {
179    config.profiles.insert(
180        "codex".into(),
181        HarnessProfile {
182            enabled: true,
183            kind: HarnessKind::Codex,
184            home: codex_home,
185            environment: BTreeMap::new(),
186            context_window_bytes: 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 profile = HarnessProfile {
302            enabled: true,
303            kind,
304            home: home.clone(),
305            environment: BTreeMap::new(),
306            context_window_bytes: None,
307        };
308        let (program, _) = mj_core::credentials::login_command(&profile);
309        let probe = CommandSpec::new(program, ["--version"])
310            .purpose("detect installed harness before first login");
311        match executor.execute(&probe) {
312            Ok(output) if output.status == 0 => homes.push(DiscoveredHome {
313                kind,
314                path: home,
315                authenticated: false,
316            }),
317            Ok(_) => {}
318            Err(error) => tracing::debug!(
319                harness = kind.id(),
320                "installation probe unavailable: {error:#}"
321            ),
322        }
323    }
324}
325
326pub fn discover_harness_homes(
327    home: Option<&Path>,
328    overrides: impl IntoIterator<Item = (HarnessKind, PathBuf)>,
329) -> Vec<DiscoveredHome> {
330    discover_harness_homes_with_executor(home, overrides, &probe_executor())
331}
332
333pub(crate) fn discover_harness_homes_with_executor(
334    home: Option<&Path>,
335    overrides: impl IntoIterator<Item = (HarnessKind, PathBuf)>,
336    executor: &impl CommandExecutor,
337) -> Vec<DiscoveredHome> {
338    let mut candidates = Vec::new();
339    if let Some(home) = home {
340        candidates.extend(
341            HarnessKind::ALL
342                .into_iter()
343                .map(|kind| (kind, home.join(kind.default_home_leaf()), true)),
344        );
345    }
346    candidates.extend(
347        overrides
348            .into_iter()
349            .map(|(kind, path)| (kind, path, false)),
350    );
351
352    let mut seen = BTreeSet::new();
353    candidates
354        .into_iter()
355        .filter(|(kind, path, _)| seen.insert((*kind, path.clone())) && path.is_dir())
356        .map(|(kind, path, is_default_home)| DiscoveredHome {
357            authenticated: harness_is_authenticated_with(kind, &path, is_default_home, executor),
358            kind,
359            path,
360        })
361        .collect()
362}
363
364pub fn harness_is_authenticated(kind: HarnessKind, home: &Path) -> bool {
365    harness_is_authenticated_with_executor(kind, home, &probe_executor())
366}
367
368pub(crate) fn harness_is_authenticated_with_executor(
369    kind: HarnessKind,
370    home: &Path,
371    executor: &impl CommandExecutor,
372) -> bool {
373    let is_default_home =
374        dirs::home_dir().is_some_and(|user_home| home == user_home.join(kind.default_home_leaf()));
375    harness_is_authenticated_with(kind, home, is_default_home, executor)
376}
377
378fn harness_is_authenticated_with(
379    kind: HarnessKind,
380    home: &Path,
381    is_default_home: bool,
382    executor: &impl CommandExecutor,
383) -> bool {
384    if harness_authentication_marker(kind, home).is_file()
385        || (kind == HarnessKind::Kimi && home.join("credentials").is_file())
386    {
387        return true;
388    }
389    if kind != HarnessKind::Claude {
390        return false;
391    }
392    if is_default_home && claude_keychain_reports_authenticated(executor) {
393        return true;
394    }
395    if !is_default_home && claude_cli_reports_authenticated(home, executor) {
396        return true;
397    }
398    false
399}
400
401/// Ask Claude Code about a scoped profile. Setting `CLAUDE_CONFIG_DIR` for the
402/// default home changes Claude's profile selection, so the default macOS
403/// profile is checked directly in the Keychain instead.
404fn claude_cli_reports_authenticated(home: &Path, executor: &impl CommandExecutor) -> bool {
405    let mut command = CommandSpec::new("claude", ["auth", "status", "--json"])
406        .purpose("check Claude Code authentication");
407    command.env.insert(
408        HarnessKind::Claude.home_env().to_owned(),
409        home.to_string_lossy().into_owned(),
410    );
411    let Ok(output) = executor.execute(&command) else {
412        return false;
413    };
414    if output.status != 0 {
415        return false;
416    }
417    serde_json::from_slice::<serde_json::Value>(&output.stdout)
418        .ok()
419        .and_then(|status| status.get("loggedIn").and_then(serde_json::Value::as_bool))
420        == Some(true)
421}
422
423/// Mjolnir and Claude Code use this service for the default macOS profile.
424/// `security` is already authorized for the item, so this does not raise a
425/// Keychain prompt; the shared executor still bounds a wedged lookup.
426#[cfg(target_os = "macos")]
427fn claude_keychain_reports_authenticated(executor: &impl CommandExecutor) -> bool {
428    let command = CommandSpec::new(
429        "security",
430        [
431            "find-generic-password",
432            "-s",
433            "Claude Code-credentials",
434            "-w",
435        ],
436    )
437    .purpose("check Claude Code authentication in the macOS Keychain");
438    let Ok(output) = executor.execute(&command) else {
439        return false;
440    };
441    output.status == 0 && claude_credentials_contain_login(&output.stdout)
442}
443
444#[cfg(not(target_os = "macos"))]
445fn claude_keychain_reports_authenticated(_executor: &impl CommandExecutor) -> bool {
446    false
447}
448
449#[cfg(any(target_os = "macos", test))]
450fn claude_credentials_contain_login(credentials: &[u8]) -> bool {
451    let Ok(document) = serde_json::from_slice::<serde_json::Value>(credentials) else {
452        return false;
453    };
454    [
455        "/claudeAiOauth/accessToken",
456        "/claudeAiOauth/refreshToken",
457        "/oauth/accessToken",
458        "/apiKey",
459    ]
460    .into_iter()
461    .any(|pointer| {
462        document
463            .pointer(pointer)
464            .and_then(serde_json::Value::as_str)
465            .is_some_and(|value| !value.trim().is_empty())
466    })
467}
468
469pub fn github_repository_from_origin(origin: &str) -> Option<GithubRepository> {
470    let origin = origin.trim();
471    let path = origin
472        .strip_prefix("https://github.com/")
473        .or_else(|| origin.strip_prefix("http://github.com/"))
474        .or_else(|| origin.strip_prefix("git@github.com:"))
475        .or_else(|| origin.strip_prefix("ssh://git@github.com/"))
476        // Config accepts owner/repository shorthand, and import uses the same
477        // parser to compare that configured source with `git remote` output.
478        .unwrap_or(origin);
479    let path = path.trim_end_matches(".git");
480    let mut parts = path.split('/');
481    let owner = parts.next()?;
482    let repository = parts.next()?;
483    if owner.is_empty()
484        || repository.is_empty()
485        || parts.next().is_some()
486        || owner.chars().any(char::is_whitespace)
487        || repository.chars().any(char::is_whitespace)
488    {
489        return None;
490    }
491    Some(GithubRepository {
492        owner: owner.to_owned(),
493        repository: repository.to_owned(),
494    })
495}
496
497/// Read the current directory's GitHub origin, through the same executor every
498/// other discovery probe uses so it is bounded and can be faked in tests.
499///
500/// `git -C` selects the directory instead of a working-directory field on the
501/// command, which no executor carries.
502fn discover_github_repository(
503    executor: &impl CommandExecutor,
504    cwd: &Path,
505) -> Option<GithubRepository> {
506    let command = CommandSpec::new(
507        "git",
508        [
509            "-C".to_owned(),
510            cwd.to_string_lossy().into_owned(),
511            "remote".to_owned(),
512            "get-url".to_owned(),
513            "origin".to_owned(),
514        ],
515    )
516    .purpose("detect the current repository's GitHub origin");
517    let output = executor.execute(&command).ok()?;
518    if output.status != 0 {
519        return None;
520    }
521    github_repository_from_origin(&String::from_utf8_lossy(&output.stdout))
522}
523
524/// Probe the container runtimes setup can configure, reusing the doctor checks
525/// so an unavailable runtime carries doctor's detail and remediation.
526pub fn probe_local_runtimes(executor: &impl CommandExecutor, is_macos: bool) -> Vec<RuntimeProbe> {
527    let mut probes = vec![
528        runtime_probe_from_check(RuntimeKind::Podman, local_podman_runtime_check(executor)),
529        runtime_probe_from_check(RuntimeKind::Docker, local_docker_runtime_check(executor)),
530    ];
531    if is_macos {
532        probes.push(runtime_probe_from_check(
533            RuntimeKind::AppleContainer,
534            apple_container_daemon_check(executor),
535        ));
536    }
537    probes
538}
539
540fn runtime_probe_from_check(kind: RuntimeKind, check: crate::doctor::DoctorCheck) -> RuntimeProbe {
541    RuntimeProbe {
542        kind,
543        usable: check.status == CheckStatus::Ready,
544        detail: check.detail,
545        remediation: check.remediation,
546    }
547}
548
549/// Detect a usable AWS CLI identity on this host.
550///
551/// Returns `None` whenever the CLI is missing or its credentials do not work,
552/// so setup can skip the AWS step instead of prompting for a target that could
553/// never launch.
554pub fn detect_aws(executor: &impl CommandExecutor) -> Option<AwsAccount> {
555    let identity = CommandSpec::new("aws", ["sts", "get-caller-identity", "--output", "json"])
556        .purpose("detect AWS credentials");
557    let output = executor.execute(&identity).ok()?;
558    if output.status != 0 {
559        return None;
560    }
561    let identity: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
562    let account = identity.get("Account")?.as_str()?.to_owned();
563    let arn = identity.get("Arn")?.as_str()?.to_owned();
564    Some(AwsAccount {
565        account,
566        arn,
567        region: configured_aws_region(executor),
568    })
569}
570
571fn configured_aws_region(executor: &impl CommandExecutor) -> Option<String> {
572    let command = CommandSpec::new("aws", ["configure", "get", "region"])
573        .purpose("read the default AWS region");
574    let output = executor.execute(&command).ok()?;
575    if output.status != 0 {
576        return None;
577    }
578    let region = String::from_utf8_lossy(&output.stdout).trim().to_owned();
579    (!region.is_empty()).then_some(region)
580}
581
582pub fn build_config(
583    homes: &[DiscoveredHome],
584    repository: Option<&GithubRepository>,
585    runtime: RuntimeKind,
586    image: &str,
587) -> Config {
588    build_config_with_runtime(homes, repository, Some((runtime, image)), None, None)
589}
590
591fn build_config_with_runtime(
592    homes: &[DiscoveredHome],
593    repository: Option<&GithubRepository>,
594    runtime: Option<(RuntimeKind, &str)>,
595    aws: Option<&AwsTargetInput>,
596    ssh: Option<&SshTargetInput>,
597) -> Config {
598    build_config_with_runtimes(
599        homes,
600        repository,
601        &runtime.into_iter().collect::<Vec<_>>(),
602        aws,
603        ssh,
604    )
605}
606
607fn build_config_with_runtimes(
608    homes: &[DiscoveredHome],
609    repository: Option<&GithubRepository>,
610    runtimes: &[(RuntimeKind, &str)],
611    aws: Option<&AwsTargetInput>,
612    ssh: Option<&SshTargetInput>,
613) -> Config {
614    let mut config = Config::default();
615    for home in homes {
616        let id = unique_id(&config.profiles, home.kind.id());
617        config.profiles.insert(
618            id,
619            HarnessProfile {
620                enabled: true,
621                kind: home.kind,
622                home: home.path.clone(),
623                environment: BTreeMap::new(),
624                context_window_bytes: None,
625            },
626        );
627    }
628
629    if let Some(repository) = repository {
630        let repository_id = config_id(&repository.repository);
631        config.bundles.insert(
632            "current-repository".to_owned(),
633            ProjectBundle {
634                primary_repo: repository_id.clone(),
635                repositories: vec![ProjectRepository {
636                    id: repository_id.clone(),
637                    github: Some(repository.source()),
638                    local: None,
639                    destination: PathBuf::from(repository_id),
640                    git_ref: None,
641                }],
642            },
643        );
644    }
645
646    #[cfg(unix)]
647    config
648        .targets
649        .insert("localhost".to_owned(), TargetTemplate::LocalBare);
650    for (runtime, image) in runtimes {
651        let (target_id, target) = local_runtime_target(*runtime, image);
652        config.targets.insert(target_id.to_owned(), target);
653    }
654    if let Some(aws) = aws {
655        config.targets.insert(
656            AWS_TARGET_ID.to_owned(),
657            TargetTemplate::AwsEc2 {
658                aws_profile: None,
659                region: aws.region.clone(),
660                launch_template: aws.launch_template.clone(),
661                launch_template_version: None,
662                ssh_user: aws.ssh_user.clone(),
663                address_source: AwsAddressSource::default(),
664                identity_file: aws.identity_file.clone(),
665                ssh_args: vec![],
666            },
667        );
668    }
669    if let Some(ssh) = ssh {
670        // Leave user and identity file unset: the SSH config alias already
671        // carries whatever the user configured for this host.
672        let connection = SshConnection {
673            host: ssh.host.clone(),
674            user: None,
675            identity_file: None,
676            extra_args: vec![],
677        };
678        let target = match &ssh.kind {
679            SshTargetKind::Bare { permissions } => TargetTemplate::SshBare {
680                ssh: connection,
681                permissions: *permissions,
682                workspace_prefix: default_ssh_workspace_prefix(),
683            },
684            SshTargetKind::Podman { image } => TargetTemplate::SshPodman {
685                ssh: connection,
686                container: ContainerTemplate {
687                    image: image.clone(),
688                    pull_policy: Default::default(),
689                    platform: None,
690                    cpus: None,
691                    memory: None,
692                    environment: BTreeMap::new(),
693                    workspace_storage: Default::default(),
694                },
695            },
696            SshTargetKind::Docker { image } => TargetTemplate::SshDocker {
697                ssh: connection,
698                container: ContainerTemplate {
699                    image: image.clone(),
700                    pull_policy: Default::default(),
701                    platform: None,
702                    cpus: None,
703                    memory: None,
704                    environment: BTreeMap::new(),
705                    workspace_storage: Default::default(),
706                },
707            },
708        };
709        // The dialog already refuses a name that collides, so this only guards
710        // a caller that builds a config without asking: a chosen SSH name must
711        // never silently replace a target configured moments earlier.
712        config
713            .targets
714            .insert(unique_id(&config.targets, &ssh.name), target);
715    }
716    config
717}
718
719/// The shared setup/startup template for a locally available container engine.
720pub fn local_runtime_target(runtime: RuntimeKind, image: &str) -> (&'static str, TargetTemplate) {
721    let container = ContainerTemplate {
722        image: image.trim().to_owned(),
723        pull_policy: Default::default(),
724        platform: None,
725        cpus: None,
726        memory: None,
727        environment: BTreeMap::new(),
728        workspace_storage: Default::default(),
729    };
730    match runtime {
731        RuntimeKind::Podman => ("podman", TargetTemplate::LocalPodman { container }),
732        RuntimeKind::Docker => ("docker", TargetTemplate::LocalDocker { container }),
733        RuntimeKind::AppleContainer => (
734            "apple-container",
735            TargetTemplate::AppleContainer { container },
736        ),
737    }
738}
739
740/// The same default `serde` applies to a hand-written `ssh-bare` target.
741fn default_ssh_workspace_prefix() -> PathBuf {
742    PathBuf::from(".local/share/hel/workspaces")
743}
744
745fn config_id(value: &str) -> String {
746    let mut id = value
747        .chars()
748        .filter(|character| {
749            character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
750        })
751        .take(64)
752        .collect::<String>();
753    if id.is_empty() || matches!(id.as_str(), "." | "..") {
754        id = "repository".to_owned();
755    }
756    id
757}
758
759/// Ask the setup questions, write the configuration, and report on it.
760///
761/// The smoke test and the closing doctor report run through different
762/// executors on purpose: a smoke test may pull a multi-gigabyte image and must
763/// not be given a deadline, while every prerequisite probe must answer quickly
764/// or be reported as a fixable check.
765pub fn run_setup_dialog_with(
766    input: &mut impl BufRead,
767    output: &mut impl Write,
768    config_path: &Path,
769    discovery: &SetupDiscovery,
770    smoke_executor: &impl CommandExecutor,
771    probe_executor: &impl CommandExecutor,
772) -> Result<SetupOutcome> {
773    run_setup_dialog_inner(
774        input,
775        output,
776        config_path,
777        discovery,
778        smoke_executor,
779        probe_executor,
780    )
781}
782
783fn run_setup_dialog_inner(
784    input: &mut impl SetupPrompter,
785    output: &mut impl Write,
786    config_path: &Path,
787    discovery: &SetupDiscovery,
788    smoke_executor: &impl CommandExecutor,
789    probe_executor: &impl CommandExecutor,
790) -> Result<SetupOutcome> {
791    let existing = Config::load_from(config_path)?;
792    writeln!(output, "Welcome to Mjolnir setup.")?;
793    writeln!(output)?;
794    write_discovered_homes(output, &discovery.homes)?;
795    write_repository(output, discovery.repository.as_ref())?;
796    write_runtimes(output, &discovery.runtimes)?;
797
798    let runtimes = if discovery.runtimes.iter().any(|runtime| runtime.usable) {
799        let image = prompt(
800            input,
801            output,
802            &format!("Container image [{DEFAULT_IMAGE}]: "),
803        )?;
804        let image = if image.is_empty() {
805            DEFAULT_IMAGE.to_owned()
806        } else {
807            image
808        };
809        discovery
810            .runtimes
811            .iter()
812            .filter(|runtime| runtime.usable)
813            .map(|runtime| (runtime.kind, image.clone()))
814            .collect::<Vec<_>>()
815    } else {
816        writeln!(
817            output,
818            "No usable container runtime found; raw localhost will still be configured."
819        )?;
820        Vec::new()
821    };
822    let aws = prompt_aws_target(input, output, discovery.aws.as_ref())?;
823    let runtime_choices = runtimes
824        .iter()
825        .map(|(runtime, image)| (*runtime, image.as_str()))
826        .collect::<Vec<_>>();
827    // Build what the earlier answers already claimed, so the SSH step can
828    // refuse a target name that would replace one of them.
829    let configured = build_config_with_runtimes(
830        &discovery.homes,
831        discovery.repository.as_ref(),
832        &runtime_choices,
833        aws.as_ref(),
834        None,
835    );
836    let ssh = prompt_ssh_target(input, output, &discovery.ssh_hosts, &configured.targets)?;
837    let config = build_config_with_runtimes(
838        &discovery.homes,
839        discovery.repository.as_ref(),
840        &runtime_choices,
841        aws.as_ref(),
842        ssh.as_ref(),
843    );
844    config.validate()?;
845    let additions = reconcile_setup(input, output, &existing, config)?;
846    let runtimes = runtimes
847        .into_iter()
848        .filter(|(runtime, image)| {
849            let (_, target) = local_runtime_target(*runtime, image);
850            additions.targets.values().any(|added| added == &target)
851        })
852        .collect::<Vec<_>>();
853
854    writeln!(output)?;
855    write_summary(output, config_path, &additions, &runtimes)?;
856    let confirmation = prompt(input, output, "Write this configuration? [y/N]: ")?;
857    if !matches!(confirmation.to_ascii_lowercase().as_str(), "y" | "yes") {
858        writeln!(output, "Setup cancelled.")?;
859        return Ok(SetupOutcome::Cancelled);
860    }
861
862    writeln!(output, "Writing {}...", config_path.display())?;
863    Config::update_to(config_path, |latest| {
864        apply_setup_additions(latest, &additions)
865    })?;
866    // A failed smoke test is a fixable prerequisite, not a reason to abandon
867    // the run: the configuration is already written, and this is exactly when
868    // the closing report's remediations matter most.
869    let smoke_failures = runtimes
870        .iter()
871        .filter_map(|(runtime, image)| {
872            let target = smoke_target(*runtime, image);
873            run_smoke_test(output, &target, smoke_executor)
874                .err()
875                .map(|error| smoke_failure_check(*runtime, image, &error))
876        })
877        .collect();
878    write_doctor_report(output, config_path, probe_executor, smoke_failures)?;
879    writeln!(
880        output,
881        "Advanced users can edit TOML for extra profiles, virtual monorepos, SSH, and AWS."
882    )?;
883    writeln!(output, "Press n to start your first session.")?;
884    Ok(SetupOutcome::Written)
885}
886
887/// Setup only adds entries. Existing identifiers may belong to live sessions.
888fn reconcile_setup(
889    input: &mut impl SetupPrompter,
890    output: &mut impl Write,
891    existing: &Config,
892    discovered: Config,
893) -> Result<Config> {
894    let mut additions = existing.setup_additions(&discovered);
895    for id in additions.profiles.keys() {
896        writeln!(output, "Adding discovered profile {id}.")?;
897    }
898    for id in additions.bundles.keys() {
899        writeln!(output, "Adding repository bundle {id}.")?;
900    }
901    for (id, target) in &discovered.targets {
902        if !existing.targets.contains_key(id) {
903            continue;
904        }
905        let alternate = additions
906            .targets
907            .iter()
908            .find(|(_, added)| *added == target)
909            .map(|(id, _)| id.clone());
910        let Some(alternate) = alternate else { continue };
911        writeln!(
912            output,
913            "Target {id} already has different settings. Existing sessions will keep using it."
914        )?;
915        let answer = prompt(
916            input,
917            output,
918            &format!("Keep {id}, or add the discovered settings as {alternate}? [K/a]: "),
919        )?;
920        if !matches!(answer.to_ascii_lowercase().as_str(), "a" | "add") {
921            additions.targets.remove(&alternate);
922            writeln!(
923                output,
924                "Keeping target {id}; discovered settings were not added."
925            )?;
926        }
927    }
928    Ok(additions)
929}
930
931fn apply_setup_additions(latest: &mut Config, additions: &Config) -> Result<()> {
932    fn add<T: Clone>(
933        section: &str,
934        latest: &mut BTreeMap<String, T>,
935        additions: &BTreeMap<String, T>,
936        same: impl Fn(&T, &T) -> bool,
937    ) -> Result<()> {
938        for (id, value) in additions {
939            if latest.values().any(|existing| same(existing, value)) {
940                continue;
941            }
942            ensure!(
943                !latest.contains_key(id),
944                "{section} {id:?} changed while setup was open; no configuration was written. Rerun mj setup to review the current settings"
945            );
946            latest.insert(id.clone(), value.clone());
947        }
948        Ok(())
949    }
950    add(
951        "profile",
952        &mut latest.profiles,
953        &additions.profiles,
954        HarnessProfile::same_installation,
955    )?;
956    add(
957        "bundle",
958        &mut latest.bundles,
959        &additions.bundles,
960        PartialEq::eq,
961    )?;
962    add(
963        "target",
964        &mut latest.targets,
965        &additions.targets,
966        PartialEq::eq,
967    )?;
968    latest.validate()
969}
970
971fn write_discovered_homes(output: &mut impl Write, homes: &[DiscoveredHome]) -> Result<()> {
972    writeln!(output, "Harness homes:")?;
973    if homes.is_empty() {
974        writeln!(
975            output,
976            "  No existing Codex, Claude Code, Kimi Code, or Grok Build homes found."
977        )?;
978    }
979    for home in homes {
980        let authentication = if home.authenticated {
981            "authenticated"
982        } else {
983            "not authenticated"
984        };
985        writeln!(
986            output,
987            "  {}: {} ({authentication}){}",
988            home.kind.display_name(),
989            home.path.display(),
990            match home.kind.unsandboxed_guardian_warning() {
991                Some(warning) => format!(" — {warning}"),
992                None => String::new(),
993            }
994        )?;
995    }
996    Ok(())
997}
998
999fn write_repository(output: &mut impl Write, repository: Option<&GithubRepository>) -> Result<()> {
1000    match repository {
1001        Some(repository) => writeln!(
1002            output,
1003            "GitHub origin: {} (a one-repository bundle will be created)",
1004            repository.source()
1005        )?,
1006        None => writeln!(
1007            output,
1008            "GitHub origin: none detected in the current directory."
1009        )?,
1010    }
1011    Ok(())
1012}
1013
1014fn write_runtimes(output: &mut impl Write, runtimes: &[RuntimeProbe]) -> Result<()> {
1015    writeln!(output, "Local runtimes:")?;
1016    for runtime in runtimes {
1017        let state = if runtime.usable {
1018            "usable"
1019        } else {
1020            "unavailable"
1021        };
1022        if runtime.detail.is_empty() {
1023            writeln!(output, "  {}: {state}", runtime.kind.label())?;
1024        } else {
1025            writeln!(
1026                output,
1027                "  {}: {state} ({})",
1028                runtime.kind.label(),
1029                runtime.detail
1030            )?;
1031        }
1032        if let Some(remediation) = &runtime.remediation {
1033            writeln!(output, "    remediation: {remediation}")?;
1034        }
1035    }
1036    Ok(())
1037}
1038
1039/// Offer an AWS EC2 target, but only when this host already has working AWS
1040/// credentials. Without them the step prints one line and asks nothing.
1041fn prompt_aws_target(
1042    input: &mut impl SetupPrompter,
1043    output: &mut impl Write,
1044    account: Option<&AwsAccount>,
1045) -> Result<Option<AwsTargetInput>> {
1046    let Some(account) = account else {
1047        writeln!(
1048            output,
1049            "AWS: no working `aws` CLI credentials found; skipping the AWS target."
1050        )?;
1051        return Ok(None);
1052    };
1053    writeln!(
1054        output,
1055        "AWS: credentials are valid for account {} ({}).",
1056        account.account, account.arn
1057    )?;
1058    let answer = prompt(input, output, "Add an AWS EC2 target? [y/N]: ")?;
1059    if !matches!(answer.to_ascii_lowercase().as_str(), "y" | "yes") {
1060        return Ok(None);
1061    }
1062
1063    let launch_template = prompt(input, output, "Launch template name: ")?;
1064    if launch_template.is_empty() {
1065        writeln!(
1066            output,
1067            "A launch template name is required; skipping the AWS target."
1068        )?;
1069        return Ok(None);
1070    }
1071
1072    let region_label = match &account.region {
1073        Some(region) => format!("Region [{region}]: "),
1074        None => "Region: ".to_owned(),
1075    };
1076    let region = prompt(input, output, &region_label)?;
1077    let region = if region.is_empty() {
1078        match &account.region {
1079            Some(region) => region.clone(),
1080            None => {
1081                writeln!(output, "A region is required; skipping the AWS target.")?;
1082                return Ok(None);
1083            }
1084        }
1085    } else {
1086        region
1087    };
1088
1089    let ssh_user = prompt(
1090        input,
1091        output,
1092        &format!("SSH user [{DEFAULT_AWS_SSH_USER}]: "),
1093    )?;
1094    let ssh_user = if ssh_user.is_empty() {
1095        DEFAULT_AWS_SSH_USER.to_owned()
1096    } else {
1097        ssh_user
1098    };
1099    let identity_file = prompt(input, output, "SSH identity file (optional): ")?;
1100
1101    Ok(Some(AwsTargetInput {
1102        launch_template,
1103        region,
1104        ssh_user,
1105        identity_file: (!identity_file.is_empty()).then(|| PathBuf::from(identity_file)),
1106    }))
1107}
1108
1109/// Offer an SSH target built from the aliases in `~/.ssh/config`.
1110///
1111/// With no aliases the step prints one line and asks nothing, the same way the
1112/// AWS step reports skipping.
1113fn prompt_ssh_target(
1114    input: &mut impl SetupPrompter,
1115    output: &mut impl Write,
1116    aliases: &[String],
1117    configured: &BTreeMap<String, TargetTemplate>,
1118) -> Result<Option<SshTargetInput>> {
1119    if aliases.is_empty() {
1120        writeln!(
1121            output,
1122            "SSH: no host aliases found in ~/.ssh/config; skipping the SSH target."
1123        )?;
1124        return Ok(None);
1125    }
1126    writeln!(output, "SSH: hosts found in ~/.ssh/config:")?;
1127    for (index, alias) in aliases.iter().enumerate() {
1128        writeln!(output, "  {}) {alias}", index + 1)?;
1129    }
1130    let answer = prompt(input, output, "Add an SSH target? [y/N]: ")?;
1131    if !matches!(answer.to_ascii_lowercase().as_str(), "y" | "yes") {
1132        return Ok(None);
1133    }
1134
1135    let choice = prompt(
1136        input,
1137        output,
1138        &format!("Host number 1-{} or a host name: ", aliases.len()),
1139    )?;
1140    let host = match choice.parse::<usize>() {
1141        Ok(index) if (1..=aliases.len()).contains(&index) => aliases[index - 1].clone(),
1142        _ if !choice.is_empty() => choice,
1143        _ => {
1144            writeln!(output, "A host is required; skipping the SSH target.")?;
1145            return Ok(None);
1146        }
1147    };
1148
1149    let kind = loop {
1150        let runtime = prompt(
1151            input,
1152            output,
1153            "Container runtime on that host, podman, docker, or bare [podman]: ",
1154        )?;
1155        let runtime = runtime.to_ascii_lowercase();
1156        if matches!(runtime.as_str(), "n" | "no" | "bare") {
1157            let permissions = loop {
1158                let mode = prompt(
1159                    input,
1160                    output,
1161                    "Raw-host permissions, guardian or yolo [guardian]: ",
1162                )?;
1163                match mode.to_ascii_lowercase().as_str() {
1164                    "" | "guardian" => break PermissionMode::Guardian,
1165                    "yolo" => break PermissionMode::Yolo,
1166                    _ => writeln!(output, "Permissions must be `guardian` or `yolo`.")?,
1167                }
1168            };
1169            break SshTargetKind::Bare { permissions };
1170        }
1171        let kind = if matches!(runtime.as_str(), "" | "y" | "yes" | "podman") {
1172            SshTargetKind::Podman {
1173                image: String::new(),
1174            }
1175        } else if runtime == "docker" {
1176            SshTargetKind::Docker {
1177                image: String::new(),
1178            }
1179        } else {
1180            writeln!(
1181                output,
1182                "Runtime must be `podman`, `docker`, or `bare`; please choose again."
1183            )?;
1184            continue;
1185        };
1186        let image = prompt(
1187            input,
1188            output,
1189            &format!("Container image [{DEFAULT_IMAGE}]: "),
1190        )?;
1191        let image = if image.is_empty() {
1192            DEFAULT_IMAGE.to_owned()
1193        } else {
1194            image
1195        };
1196        break match kind {
1197            SshTargetKind::Podman { .. } => SshTargetKind::Podman { image },
1198            SshTargetKind::Docker { .. } => SshTargetKind::Docker { image },
1199            SshTargetKind::Bare { .. } => unreachable!("bare runtime returned above"),
1200        };
1201    };
1202
1203    let Some(name) = prompt_ssh_target_name(input, output, &host, configured)? else {
1204        return Ok(None);
1205    };
1206
1207    Ok(Some(SshTargetInput { name, host, kind }))
1208}
1209
1210/// Ask for the SSH target's name until the answer is a usable target id.
1211///
1212/// Both failures are caught here rather than by `config.validate()` after every
1213/// question has been asked: an invalid id would otherwise discard the whole
1214/// dialog, and a name that is already taken would silently replace the target
1215/// it collides with.
1216fn prompt_ssh_target_name(
1217    input: &mut impl SetupPrompter,
1218    output: &mut impl Write,
1219    host: &str,
1220    configured: &BTreeMap<String, TargetTemplate>,
1221) -> Result<Option<String>> {
1222    loop {
1223        let Some(answer) = prompt_line(input, output, &format!("Target name [{host}]: "))? else {
1224            writeln!(output, "Input ended; skipping the SSH target.")?;
1225            return Ok(None);
1226        };
1227        let name = if answer.is_empty() {
1228            host.to_owned()
1229        } else {
1230            answer
1231        };
1232        if let Err(error) = validate_id("target", &name) {
1233            writeln!(output, "{error}")?;
1234            continue;
1235        }
1236        if configured.contains_key(&name) {
1237            writeln!(
1238                output,
1239                "Target {name} is already configured; choose another name."
1240            )?;
1241            continue;
1242        }
1243        return Ok(Some(name));
1244    }
1245}
1246
1247/// Phrase a failed setup smoke test the way `mj doctor --smoke` phrases the
1248/// same failure, so it joins the closing report instead of ending the run.
1249fn smoke_failure_check(runtime: RuntimeKind, image: &str, error: &anyhow::Error) -> DoctorCheck {
1250    let scope = match runtime {
1251        RuntimeKind::Docker => "Disposable run/exec/remove and OverlayFS attachment smoke test",
1252        RuntimeKind::Podman | RuntimeKind::AppleContainer => {
1253            "Disposable run/exec/remove smoke test"
1254        }
1255    };
1256    DoctorCheck::fixable(
1257        format!("runtime.{}.smoke", runtime.id()),
1258        format!("{} smoke test", runtime.label()),
1259        format!("{scope} failed for image {image}: {error:#}"),
1260        format!(
1261            "Fix the configured image or the {} runtime, then run `mj doctor --smoke` again.",
1262            runtime.label()
1263        ),
1264    )
1265}
1266
1267/// End setup with the same report `mj doctor` prints, so the user gets one
1268/// ready/fixable summary with remediations instead of two different signals.
1269///
1270/// `extra` carries anything setup itself learned that doctor cannot repeat
1271/// without the opt-in smoke test.
1272fn write_doctor_report(
1273    output: &mut impl Write,
1274    config_path: &Path,
1275    executor: &impl CommandExecutor,
1276    extra: Vec<DoctorCheck>,
1277) -> Result<()> {
1278    writeln!(output)?;
1279    writeln!(output, "Running `mj doctor` checks on the new config...")?;
1280    let mut checks = run_with_config_path(
1281        config_path,
1282        executor,
1283        current_apple_platform(executor),
1284        DoctorOptions { smoke: false },
1285    );
1286    checks.extend(extra);
1287    render_human(&checks, output)?;
1288    if all_ready(&checks) {
1289        writeln!(output, "Every check is ready.")?;
1290    } else {
1291        writeln!(
1292            output,
1293            "Apply the remediations above, then rerun `mj doctor`."
1294        )?;
1295    }
1296    Ok(())
1297}
1298
1299fn prompt(input: &mut impl SetupPrompter, output: &mut impl Write, label: &str) -> Result<String> {
1300    Ok(prompt_line(input, output, label)?.unwrap_or_default())
1301}
1302
1303/// Read one answer, reporting `None` once the input has ended.
1304///
1305/// Every question but one treats the end of input as an empty answer and takes
1306/// its default. A question that must be asked again until it is answered needs
1307/// the difference, or it would loop forever against a closed stdin.
1308fn prompt_line(
1309    input: &mut impl SetupPrompter,
1310    output: &mut impl Write,
1311    label: &str,
1312) -> Result<Option<String>> {
1313    input.read_prompt(output, label)
1314}
1315
1316trait SetupPrompter {
1317    fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>>;
1318}
1319
1320impl<R: BufRead> SetupPrompter for R {
1321    fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>> {
1322        write!(output, "{label}")?;
1323        output.flush()?;
1324        let mut answer = String::new();
1325        let read = self.read_line(&mut answer).context("read setup response")?;
1326        Ok((read > 0).then(|| answer.trim().to_owned()))
1327    }
1328}
1329
1330#[derive(Default)]
1331struct ReadlinePrompter(crate::readline::LineReader);
1332
1333impl SetupPrompter for ReadlinePrompter {
1334    fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>> {
1335        output.flush()?;
1336        self.0.read_line(label).context("read setup response")
1337    }
1338}
1339
1340fn write_summary(
1341    output: &mut impl Write,
1342    config_path: &Path,
1343    config: &Config,
1344    runtimes: &[(RuntimeKind, String)],
1345) -> Result<()> {
1346    writeln!(output, "Mjolnir will add to {}:", config_path.display())?;
1347    writeln!(output, "  {} profile(s)", config.profiles.len())?;
1348    writeln!(output, "  {} bundle(s)", config.bundles.len())?;
1349    if config
1350        .targets
1351        .values()
1352        .any(|target| matches!(target, TargetTemplate::LocalBare))
1353    {
1354        writeln!(
1355            output,
1356            "  raw localhost target using configured harness homes directly"
1357        )?;
1358    }
1359    for (runtime, image) in runtimes {
1360        writeln!(output, "  {} target using {image}", runtime.label())?;
1361    }
1362    for id in config.targets.keys() {
1363        writeln!(output, "  target id: {id}")?;
1364    }
1365    if let Some(TargetTemplate::AwsEc2 {
1366        launch_template,
1367        region,
1368        ..
1369    }) = config.targets.get(AWS_TARGET_ID)
1370    {
1371        writeln!(
1372            output,
1373            "  AWS EC2 target using launch template {launch_template} in {region}"
1374        )?;
1375    }
1376    for (id, target) in &config.targets {
1377        match target {
1378            TargetTemplate::SshBare { ssh, .. } => {
1379                writeln!(output, "  SSH target {id} on {} (no container)", ssh.host)?;
1380            }
1381            TargetTemplate::SshPodman { ssh, container, .. } => {
1382                writeln!(
1383                    output,
1384                    "  SSH target {id} on {} using Podman image {}",
1385                    ssh.host, container.image
1386                )?;
1387            }
1388            TargetTemplate::SshDocker { ssh, container } => {
1389                writeln!(
1390                    output,
1391                    "  SSH target {id} on {} using Docker image {}",
1392                    ssh.host, container.image
1393                )?;
1394            }
1395            _ => {}
1396        }
1397    }
1398    if config_path.exists() {
1399        writeln!(
1400            output,
1401            "  Existing profiles, bundles, targets, and preferences will be preserved."
1402        )?;
1403    }
1404    Ok(())
1405}
1406
1407fn smoke_target(runtime: RuntimeKind, image: &str) -> RuntimeTargetTemplate {
1408    let container = RuntimeContainerTemplate {
1409        image: image.to_owned(),
1410        pull_policy: Default::default(),
1411        extra_run_args: vec![],
1412        workspace_storage: Default::default(),
1413    };
1414    match runtime {
1415        RuntimeKind::Podman => RuntimeTargetTemplate::LocalPodman(container),
1416        RuntimeKind::Docker => RuntimeTargetTemplate::LocalDocker(container),
1417        RuntimeKind::AppleContainer => RuntimeTargetTemplate::AppleContainer(container),
1418    }
1419}
1420
1421fn run_smoke_test(
1422    output: &mut impl Write,
1423    target: &RuntimeTargetTemplate,
1424    executor: &impl CommandExecutor,
1425) -> Result<()> {
1426    let smoke_id = format!(
1427        "setup-{}-{:x}",
1428        std::process::id(),
1429        SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos()
1430    );
1431    let description = match target {
1432        RuntimeTargetTemplate::LocalDocker(_) => {
1433            "Smoke test: verifying a disposable container and writable OverlayFS attachment..."
1434        }
1435        _ => "Smoke test: verifying a disposable container...",
1436    };
1437    writeln!(output, "{description}")?;
1438    run_setup_smoke_test(target, &smoke_id, executor)
1439}
1440
1441#[cfg(test)]
1442mod tests {
1443    use std::cell::RefCell;
1444    use std::fs;
1445
1446    use super::*;
1447    use crate::targets::CommandOutput;
1448
1449    #[cfg(unix)]
1450    #[test]
1451    fn first_terminal_launch_writes_a_local_codex_config_once() {
1452        let directory = tempfile::tempdir().unwrap();
1453        let path = directory.path().join("config.toml");
1454        initialize_local_startup_config(&path).unwrap();
1455        let config = Config::load_from(&path).unwrap();
1456        assert_eq!(config.profiles["codex"].kind, HarnessKind::Codex);
1457        assert!(config.profiles["codex"].home.is_absolute());
1458        assert!(matches!(
1459            config.targets["localhost"],
1460            TargetTemplate::LocalBare
1461        ));
1462        assert!(config.bundles.is_empty());
1463        let written = fs::read(&path).unwrap();
1464        initialize_local_startup_config(&path).unwrap();
1465        assert_eq!(fs::read(&path).unwrap(), written);
1466    }
1467
1468    #[cfg(unix)]
1469    #[test]
1470    fn local_startup_preserves_existing_settings_and_ignores_disabled_startup() {
1471        let directory = tempfile::tempdir().unwrap();
1472        let path = directory.path().join("config.toml");
1473        let mut config = Config::default();
1474        config.phone.enabled = false;
1475        config.save_to(&path).unwrap();
1476        initialize_local_startup_config(&path).unwrap();
1477        assert!(!Config::load_from(&path).unwrap().phone.enabled);
1478
1479        // Even a partially configured installation belongs to the user.
1480        let configured =
1481            "version = 2\n# keep this comment\n[targets.custom]\nkind = 'local-bare'\n";
1482        fs::write(&path, configured).unwrap();
1483        initialize_local_startup_config(&path).unwrap();
1484        assert_eq!(fs::read_to_string(&path).unwrap(), configured);
1485
1486        let disabled = "version = 2\n[startup]\nenabled = false\n";
1487        fs::write(&path, disabled).unwrap();
1488        initialize_local_startup_config(&path).unwrap();
1489        let bootstrapped = Config::load_from(&path).unwrap();
1490        assert!(
1491            serde_json::to_value(&bootstrapped)
1492                .unwrap()
1493                .get("startup")
1494                .is_none()
1495        );
1496        assert_eq!(bootstrapped.profiles["codex"].kind, HarnessKind::Codex);
1497        assert!(matches!(
1498            bootstrapped.targets["localhost"],
1499            TargetTemplate::LocalBare
1500        ));
1501
1502        let newer = "version = 999\nfuture_field = true\n";
1503        fs::write(&path, newer).unwrap();
1504        initialize_local_startup_config(&path).unwrap();
1505        assert_eq!(fs::read_to_string(&path).unwrap(), newer);
1506    }
1507
1508    struct FakeExecutor {
1509        commands: RefCell<Vec<CommandSpec>>,
1510        statuses: Vec<i32>,
1511    }
1512
1513    impl FakeExecutor {
1514        fn succeeds() -> Self {
1515            Self {
1516                commands: RefCell::new(vec![]),
1517                statuses: vec![0, 0, 0],
1518            }
1519        }
1520    }
1521
1522    impl CommandExecutor for FakeExecutor {
1523        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1524            let index = self.commands.borrow().len();
1525            self.commands.borrow_mut().push(command.clone());
1526            Ok(CommandOutput {
1527                status: self.statuses.get(index).copied().unwrap_or(0),
1528                stdout: b"available".to_vec(),
1529                stderr: b"failed".to_vec(),
1530            })
1531        }
1532    }
1533
1534    struct RuntimeProbeExecutor {
1535        commands: RefCell<Vec<CommandSpec>>,
1536        outputs: RefCell<Vec<CommandOutput>>,
1537    }
1538
1539    impl RuntimeProbeExecutor {
1540        fn new(outputs: impl IntoIterator<Item = CommandOutput>) -> Self {
1541            Self {
1542                commands: RefCell::new(vec![]),
1543                outputs: RefCell::new(outputs.into_iter().collect()),
1544            }
1545        }
1546    }
1547
1548    impl CommandExecutor for RuntimeProbeExecutor {
1549        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1550            self.commands.borrow_mut().push(command.clone());
1551            if self.outputs.borrow().is_empty() {
1552                anyhow::bail!("no canned output for {}", command.program);
1553            }
1554            Ok(self.outputs.borrow_mut().remove(0))
1555        }
1556    }
1557
1558    fn ok(stdout: &[u8]) -> CommandOutput {
1559        CommandOutput {
1560            status: 0,
1561            stdout: stdout.to_vec(),
1562            stderr: vec![],
1563        }
1564    }
1565
1566    fn failed(stderr: &[u8]) -> CommandOutput {
1567        CommandOutput {
1568            status: 1,
1569            stdout: vec![],
1570            stderr: stderr.to_vec(),
1571        }
1572    }
1573
1574    const CALLER_IDENTITY: &[u8] =
1575        br#"{"UserId":"AIDA","Account":"123456789012","Arn":"arn:aws:iam::123456789012:user/dev"}"#;
1576
1577    fn discovery_without_runtimes() -> SetupDiscovery {
1578        SetupDiscovery {
1579            homes: vec![],
1580            repository: None,
1581            runtimes: vec![],
1582            aws: None,
1583            ssh_hosts: vec![],
1584        }
1585    }
1586
1587    #[test]
1588    fn newly_installed_harness_is_discovered_before_its_first_login() {
1589        struct InstalledMuse;
1590        impl CommandExecutor for InstalledMuse {
1591            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1592                assert_eq!(command.args, ["--version"]);
1593                Ok(CommandOutput {
1594                    status: if command.program == "muse" { 0 } else { 127 },
1595                    stdout: Vec::new(),
1596                    stderr: Vec::new(),
1597                })
1598            }
1599        }
1600        let directory = tempfile::tempdir().unwrap();
1601        let path = directory.path().join("custom-muse-home");
1602        let overrides = BTreeMap::from([(HarnessKind::Muse, path.clone())]);
1603        let mut homes = Vec::new();
1604        for _ in 0..2 {
1605            discover_installed_harnesses(
1606                Some(directory.path()),
1607                &overrides,
1608                &mut homes,
1609                &InstalledMuse,
1610            );
1611            assert_eq!(
1612                homes,
1613                vec![DiscoveredHome {
1614                    kind: HarnessKind::Muse,
1615                    path: path.clone(),
1616                    authenticated: false
1617                }]
1618            );
1619            assert!(!path.exists(), "discovery must not create a profile");
1620        }
1621    }
1622
1623    #[test]
1624    fn discovers_default_and_overridden_homes_with_authentication_markers() {
1625        let directory = tempfile::tempdir().unwrap();
1626        let home = directory.path().join("home");
1627        let codex = home.join(".codex");
1628        let kimi = home.join(".kimi-code");
1629        let grok = home.join(".grok");
1630        let deepseek = home.join(".dsh");
1631        let claude = directory.path().join("claude-override");
1632        fs::create_dir_all(&codex).unwrap();
1633        fs::create_dir_all(kimi.join("credentials")).unwrap();
1634        fs::create_dir_all(&grok).unwrap();
1635        fs::create_dir_all(&deepseek).unwrap();
1636        fs::create_dir_all(&claude).unwrap();
1637        fs::write(codex.join("auth.json"), "{}").unwrap();
1638        fs::write(kimi.join("credentials/kimi-code.json"), "{}").unwrap();
1639        fs::write(grok.join("auth.json"), "{}").unwrap();
1640        fs::write(deepseek.join(".credentials.yaml"), "version: 1\n").unwrap();
1641        fs::write(claude.join(".credentials.json"), "{}").unwrap();
1642
1643        let executor = FakeExecutor::succeeds();
1644        let homes = discover_harness_homes_with_executor(
1645            Some(&home),
1646            [(HarnessKind::Claude, claude.clone())],
1647            &executor,
1648        );
1649
1650        assert_eq!(homes.len(), 5);
1651        assert!(homes.iter().all(|home| home.authenticated));
1652        assert!(homes.iter().any(|home| home.path == codex));
1653        assert!(homes.iter().any(|home| home.path == claude));
1654        assert!(homes.iter().any(|home| home.path == kimi));
1655        assert!(
1656            homes
1657                .iter()
1658                .any(|home| { home.path == deepseek && home.kind == HarnessKind::Deepseek })
1659        );
1660        assert!(
1661            homes
1662                .iter()
1663                .any(|home| home.path == grok && home.kind == HarnessKind::Grok)
1664        );
1665    }
1666
1667    #[test]
1668    fn every_harness_has_a_discoverable_default_home() {
1669        let directory = tempfile::tempdir().unwrap();
1670        let home = directory.path().to_path_buf();
1671        for kind in HarnessKind::ALL {
1672            fs::create_dir_all(home.join(kind.default_home_leaf())).unwrap();
1673        }
1674
1675        let executor = FakeExecutor::succeeds();
1676        let homes = discover_harness_homes_with_executor(Some(&home), [], &executor);
1677
1678        assert_eq!(homes.len(), HarnessKind::ALL.len());
1679        for kind in HarnessKind::ALL {
1680            assert!(
1681                homes
1682                    .iter()
1683                    .any(|home| home.kind == kind && !home.authenticated),
1684                "{kind:?} default home"
1685            );
1686        }
1687    }
1688
1689    #[cfg(target_os = "macos")]
1690    #[test]
1691    fn claude_keychain_marks_the_default_home_authenticated_without_a_marker() {
1692        let directory = tempfile::tempdir().unwrap();
1693        let home = directory.path().join("home");
1694        let claude = home.join(".claude");
1695        fs::create_dir_all(&claude).unwrap();
1696        let executor = RuntimeProbeExecutor::new([ok(
1697            br#"{"claudeAiOauth":{"accessToken":"access","refreshToken":"refresh"}}"#,
1698        )]);
1699
1700        let homes = discover_harness_homes_with_executor(Some(&home), [], &executor);
1701
1702        assert_eq!(
1703            homes,
1704            vec![DiscoveredHome {
1705                kind: HarnessKind::Claude,
1706                path: claude.clone(),
1707                authenticated: true,
1708            }]
1709        );
1710        let commands = executor.commands.borrow();
1711        assert_eq!(commands.len(), 1);
1712        assert_eq!(commands[0].program, "security");
1713        assert_eq!(
1714            commands[0].args,
1715            [
1716                "find-generic-password",
1717                "-s",
1718                "Claude Code-credentials",
1719                "-w"
1720            ]
1721        );
1722        assert!(commands[0].env.is_empty());
1723    }
1724
1725    #[test]
1726    fn claude_status_checks_a_custom_home_without_a_marker() {
1727        let directory = tempfile::tempdir().unwrap();
1728        let claude = directory.path().join("claude-custom");
1729        fs::create_dir_all(&claude).unwrap();
1730        let executor =
1731            RuntimeProbeExecutor::new([ok(br#"{"loggedIn":true,"authMethod":"claude.ai"}"#)]);
1732
1733        let homes = discover_harness_homes_with_executor(
1734            None,
1735            [(HarnessKind::Claude, claude.clone())],
1736            &executor,
1737        );
1738
1739        assert_eq!(
1740            homes,
1741            vec![DiscoveredHome {
1742                kind: HarnessKind::Claude,
1743                path: claude.clone(),
1744                authenticated: true,
1745            }]
1746        );
1747        let commands = executor.commands.borrow();
1748        assert_eq!(commands.len(), 1);
1749        assert_eq!(commands[0].program, "claude");
1750        assert_eq!(commands[0].args, ["auth", "status", "--json"]);
1751        assert_eq!(
1752            commands[0].env.get("CLAUDE_CONFIG_DIR"),
1753            Some(&claude.to_string_lossy().into_owned())
1754        );
1755    }
1756
1757    #[test]
1758    fn claude_credential_evidence_requires_a_nonempty_login_secret() {
1759        assert!(claude_credentials_contain_login(
1760            br#"{"claudeAiOauth":{"refreshToken":"refresh"}}"#
1761        ));
1762        assert!(!claude_credentials_contain_login(
1763            br#"{"claudeAiOauth":{"refreshToken":"  "}}"#
1764        ));
1765        assert!(!claude_credentials_contain_login(b"not json"));
1766    }
1767
1768    #[test]
1769    fn github_origin_parser_accepts_standard_https_and_ssh_forms() {
1770        for origin in [
1771            "https://github.com/BrokkAi/hel.git",
1772            "git@github.com:BrokkAi/hel.git",
1773            "ssh://git@github.com/BrokkAi/hel.git",
1774        ] {
1775            assert_eq!(
1776                github_repository_from_origin(origin),
1777                Some(GithubRepository {
1778                    owner: "BrokkAi".into(),
1779                    repository: "hel".into(),
1780                })
1781            );
1782        }
1783        assert_eq!(
1784            github_repository_from_origin("https://example.com/hel"),
1785            None
1786        );
1787    }
1788
1789    #[test]
1790    fn config_contains_discovered_profiles_current_repository_and_selected_target() {
1791        let homes = vec![
1792            DiscoveredHome {
1793                kind: HarnessKind::Codex,
1794                path: PathBuf::from("/profiles/codex"),
1795                authenticated: true,
1796            },
1797            DiscoveredHome {
1798                kind: HarnessKind::Codex,
1799                path: PathBuf::from("/profiles/codex-two"),
1800                authenticated: false,
1801            },
1802        ];
1803        let repository = GithubRepository {
1804            owner: "BrokkAi".into(),
1805            repository: "hel".into(),
1806        };
1807
1808        let config = build_config(
1809            &homes,
1810            Some(&repository),
1811            RuntimeKind::Podman,
1812            "ubuntu:24.04",
1813        );
1814
1815        config.validate().unwrap();
1816        assert!(config.profiles.contains_key("codex"));
1817        assert!(config.profiles.contains_key("codex-2"));
1818        assert_eq!(
1819            config.bundles["current-repository"].repositories[0]
1820                .github
1821                .as_deref(),
1822            Some("BrokkAi/hel")
1823        );
1824        assert!(matches!(
1825            config.targets["podman"],
1826            TargetTemplate::LocalPodman { .. }
1827        ));
1828        assert!(matches!(
1829            config.targets["localhost"],
1830            TargetTemplate::LocalBare
1831        ));
1832
1833        let docker = build_config(
1834            &homes,
1835            Some(&repository),
1836            RuntimeKind::Docker,
1837            "ubuntu:24.04",
1838        );
1839        assert!(matches!(
1840            docker.targets["docker"],
1841            TargetTemplate::LocalDocker { .. }
1842        ));
1843    }
1844
1845    #[test]
1846    fn runtime_probe_requires_podman_rootless_preflight_and_checks_apple_on_macos() {
1847        let executor = RuntimeProbeExecutor::new([
1848            ok(b"podman version 5.4.2\n"),
1849            ok(b"true\n"),
1850            ok(b"0 1000 1\n1 100000 65536\n"),
1851            ok(b"29.0.1 linux\n"),
1852            ok(b"container version 1\n"),
1853            ok(b"running\n"),
1854        ]);
1855        let runtimes = probe_local_runtimes(&executor, true);
1856
1857        assert_eq!(runtimes.len(), 3);
1858        assert_eq!(executor.commands.borrow()[0].program, "podman");
1859        assert_eq!(executor.commands.borrow()[0].args, ["--version"]);
1860        assert_eq!(
1861            executor.commands.borrow()[1].args,
1862            ["info", "--format", "{{.Host.Security.Rootless}}"]
1863        );
1864        assert_eq!(
1865            executor.commands.borrow()[2].args,
1866            ["unshare", "cat", "/proc/self/uid_map"]
1867        );
1868        assert_eq!(executor.commands.borrow()[3].program, "docker");
1869        assert_eq!(executor.commands.borrow()[4].program, "container");
1870        assert!(runtimes.iter().all(|runtime| runtime.usable));
1871    }
1872
1873    #[test]
1874    fn unusable_podman_carries_the_doctor_remediation_into_the_runtime_list() {
1875        let executor = RuntimeProbeExecutor::new([
1876            ok(b"podman version 3.4.7\n"),
1877            failed(b"docker is unavailable"),
1878        ]);
1879
1880        let runtimes = probe_local_runtimes(&executor, false);
1881
1882        assert_eq!(runtimes.len(), 2);
1883        assert!(!runtimes[0].usable);
1884        let remediation = runtimes[0].remediation.as_deref().unwrap();
1885        assert!(remediation.contains("Upgrade Podman"), "{remediation}");
1886
1887        let mut output = Vec::new();
1888        write_runtimes(&mut output, &runtimes).unwrap();
1889        let output = String::from_utf8(output).unwrap();
1890        assert!(output.contains("Podman: unavailable"), "{output}");
1891        assert!(output.contains("Docker: unavailable"), "{output}");
1892        assert!(output.contains("remediation: Upgrade Podman"), "{output}");
1893    }
1894
1895    #[test]
1896    fn aws_is_detected_only_when_the_caller_identity_call_succeeds() {
1897        let missing = RuntimeProbeExecutor::new([]);
1898        assert_eq!(detect_aws(&missing), None);
1899
1900        let denied = RuntimeProbeExecutor::new([failed(b"ExpiredToken")]);
1901        assert_eq!(detect_aws(&denied), None);
1902
1903        let working = RuntimeProbeExecutor::new([ok(CALLER_IDENTITY), ok(b"us-east-1\n")]);
1904        assert_eq!(
1905            detect_aws(&working),
1906            Some(AwsAccount {
1907                account: "123456789012".into(),
1908                arn: "arn:aws:iam::123456789012:user/dev".into(),
1909                region: Some("us-east-1".into()),
1910            })
1911        );
1912        assert_eq!(working.commands.borrow()[0].args[0], "sts");
1913        assert_eq!(
1914            working.commands.borrow()[1].args,
1915            ["configure", "get", "region"]
1916        );
1917    }
1918
1919    #[test]
1920    fn aws_detection_without_a_configured_region_leaves_the_region_unset() {
1921        let executor = RuntimeProbeExecutor::new([ok(CALLER_IDENTITY), failed(b"")]);
1922
1923        assert_eq!(detect_aws(&executor).unwrap().region, None);
1924    }
1925
1926    #[test]
1927    fn the_aws_step_asks_nothing_when_no_aws_credentials_were_detected() {
1928        let mut input = b"".as_slice();
1929        let mut output = Vec::new();
1930
1931        let aws = prompt_aws_target(&mut input, &mut output, None).unwrap();
1932
1933        assert_eq!(aws, None);
1934        let output = String::from_utf8(output).unwrap();
1935        assert!(output.contains("skipping the AWS target"), "{output}");
1936        assert!(!output.contains("[y/N]"), "{output}");
1937    }
1938
1939    #[test]
1940    fn the_aws_step_defaults_region_and_ssh_user_when_the_answers_are_blank() {
1941        let account = AwsAccount {
1942            account: "123456789012".into(),
1943            arn: "arn:aws:iam::123456789012:user/dev".into(),
1944            region: Some("us-east-1".into()),
1945        };
1946        let mut input = b"y\nhel-runson\n\n\n\n".as_slice();
1947        let mut output = Vec::new();
1948
1949        let aws = prompt_aws_target(&mut input, &mut output, Some(&account))
1950            .unwrap()
1951            .unwrap();
1952
1953        assert_eq!(
1954            aws,
1955            AwsTargetInput {
1956                launch_template: "hel-runson".into(),
1957                region: "us-east-1".into(),
1958                ssh_user: DEFAULT_AWS_SSH_USER.into(),
1959                identity_file: None,
1960            }
1961        );
1962        let config = build_config_with_runtime(&[], None, None, Some(&aws), None);
1963        let TargetTemplate::AwsEc2 {
1964            region,
1965            launch_template,
1966            ssh_user,
1967            ..
1968        } = &config.targets[AWS_TARGET_ID]
1969        else {
1970            panic!("setup must write an aws-ec2 target");
1971        };
1972        assert_eq!(region, "us-east-1");
1973        assert_eq!(launch_template, "hel-runson");
1974        assert_eq!(ssh_user, DEFAULT_AWS_SSH_USER);
1975        config.validate().unwrap();
1976    }
1977
1978    const SSH_CONFIG_FIXTURE: &str = r#"
1979# Personal hosts
1980Host *
1981    ServerAliveInterval 60
1982
1983Host builder build.example.com
1984    HostName build.example.com
1985    User dev
1986
1987Host bastion
1988  HostName 10.0.0.1
1989  IdentityFile ~/.ssh/id_ed25519
1990
1991Host prod-*
1992    User deploy
1993
1994Host !staging *.internal
1995    User deploy
1996
1997Host builder
1998    Compression yes
1999"#;
2000
2001    #[test]
2002    fn ssh_config_parsing_keeps_concrete_aliases_and_drops_pattern_blocks() {
2003        let aliases = ssh_config_aliases(SSH_CONFIG_FIXTURE);
2004
2005        assert_eq!(
2006            aliases,
2007            vec!["builder", "build.example.com", "bastion"],
2008            "wildcard, negated, and duplicate entries must not appear"
2009        );
2010    }
2011
2012    #[test]
2013    fn ssh_config_parsing_returns_nothing_for_a_config_of_only_wildcards() {
2014        assert!(
2015            ssh_config_aliases(
2016                "Host *
2017  User dev
2018"
2019            )
2020            .is_empty()
2021        );
2022        assert!(ssh_config_aliases("").is_empty());
2023    }
2024
2025    #[test]
2026    fn the_ssh_step_asks_nothing_when_the_ssh_config_has_no_aliases() {
2027        let mut input = b"".as_slice();
2028        let mut output = Vec::new();
2029
2030        assert_eq!(
2031            prompt_ssh_target(&mut input, &mut output, &[], &BTreeMap::new()).unwrap(),
2032            None
2033        );
2034        let output = String::from_utf8(output).unwrap();
2035        assert!(output.contains("skipping the SSH target"), "{output}");
2036        assert!(!output.contains("[y/N]"), "{output}");
2037    }
2038
2039    #[test]
2040    fn declining_the_ssh_step_writes_no_ssh_target() {
2041        let aliases = vec!["builder".to_owned()];
2042        let mut input = b"\n".as_slice();
2043        let mut output = Vec::new();
2044
2045        assert_eq!(
2046            prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new()).unwrap(),
2047            None
2048        );
2049        let config = build_config_with_runtime(&[], None, None, None, None);
2050        assert!(!config.targets.values().any(|target| matches!(
2051            target,
2052            TargetTemplate::SshBare { .. }
2053                | TargetTemplate::SshPodman { .. }
2054                | TargetTemplate::SshDocker { .. }
2055        )));
2056    }
2057
2058    #[test]
2059    fn accepting_the_ssh_step_can_write_a_docker_target() {
2060        let aliases = vec!["builder".to_owned()];
2061        // yes, host 1, Docker, default image, and the default target name.
2062        let mut input = b"y\n1\ndocker\n\n\n".as_slice();
2063        let mut output = Vec::new();
2064
2065        let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new())
2066            .unwrap()
2067            .unwrap();
2068        assert_eq!(
2069            ssh.kind,
2070            SshTargetKind::Docker {
2071                image: DEFAULT_IMAGE.into()
2072            }
2073        );
2074        let config = build_config_with_runtime(&[], None, None, None, Some(&ssh));
2075        let TargetTemplate::SshDocker { ssh, container } = &config.targets["builder"] else {
2076            panic!("setup must write an ssh-docker target");
2077        };
2078        assert_eq!(ssh.host, "builder");
2079        assert_eq!(container.image, DEFAULT_IMAGE);
2080        config.validate().unwrap();
2081    }
2082
2083    #[test]
2084    fn the_ssh_step_rejects_an_unknown_runtime_before_asking_for_an_image() {
2085        let aliases = vec!["builder".to_owned()];
2086        let mut input = b"y\n1\ncontainerd\ndocker\n\n\n".as_slice();
2087        let mut output = Vec::new();
2088
2089        let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new())
2090            .unwrap()
2091            .unwrap();
2092
2093        assert!(matches!(ssh.kind, SshTargetKind::Docker { .. }));
2094        let output = String::from_utf8(output).unwrap();
2095        assert!(output.contains("Runtime must be"), "{output}");
2096        assert_eq!(output.matches("Container image").count(), 1);
2097    }
2098
2099    #[test]
2100    fn accepting_the_ssh_step_writes_an_ssh_podman_target_with_the_default_image() {
2101        let aliases = vec!["builder".to_owned(), "bastion".to_owned()];
2102        // yes, host 1, podman (default), default image and name.
2103        let mut input = b"y\n1\n\n\n\n".as_slice();
2104        let mut output = Vec::new();
2105
2106        let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new())
2107            .unwrap()
2108            .unwrap();
2109
2110        assert_eq!(
2111            ssh,
2112            SshTargetInput {
2113                name: "builder".into(),
2114                host: "builder".into(),
2115                kind: SshTargetKind::Podman {
2116                    image: DEFAULT_IMAGE.into()
2117                },
2118            }
2119        );
2120        let config = build_config_with_runtime(&[], None, None, None, Some(&ssh));
2121        let TargetTemplate::SshPodman { ssh, container, .. } = &config.targets["builder"] else {
2122            panic!("setup must write an ssh-podman target");
2123        };
2124        assert_eq!(ssh.host, "builder");
2125        assert_eq!(ssh.user, None);
2126        assert_eq!(ssh.identity_file, None);
2127        assert_eq!(container.image, DEFAULT_IMAGE);
2128        config.validate().unwrap();
2129    }
2130
2131    #[test]
2132    fn accepting_the_ssh_step_writes_an_ssh_bare_target_under_a_chosen_name() {
2133        let aliases = vec!["builder".to_owned()];
2134        // yes, typed host, default guardian permissions, no podman, custom name.
2135        let mut input = b"y\nother.example.com\nn\n\nremote\n".as_slice();
2136        let mut output = Vec::new();
2137
2138        let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new())
2139            .unwrap()
2140            .unwrap();
2141
2142        assert_eq!(
2143            ssh,
2144            SshTargetInput {
2145                name: "remote".into(),
2146                host: "other.example.com".into(),
2147                kind: SshTargetKind::Bare {
2148                    permissions: PermissionMode::Guardian,
2149                },
2150            }
2151        );
2152        let config = build_config_with_runtime(&[], None, None, None, Some(&ssh));
2153        let TargetTemplate::SshBare {
2154            ssh, permissions, ..
2155        } = &config.targets["remote"]
2156        else {
2157            panic!("setup must write an ssh-bare target");
2158        };
2159        assert_eq!(ssh.host, "other.example.com");
2160        assert_eq!(*permissions, PermissionMode::Guardian);
2161        config.validate().unwrap();
2162    }
2163
2164    #[test]
2165    fn the_ssh_step_reasks_until_the_name_is_a_free_and_valid_target_id() {
2166        let aliases = vec!["builder".to_owned()];
2167        let configured = build_config_with_runtime(
2168            &[],
2169            None,
2170            Some((RuntimeKind::Podman, DEFAULT_IMAGE)),
2171            None,
2172            None,
2173        )
2174        .targets;
2175        // yes, host 1, no podman, then: a name that is already taken, a name
2176        // that is not a usable id, and finally a free one.
2177        let mut input = b"y\n1\nn\n\npodman\nbuild host\nbuilder\n".as_slice();
2178        let mut output = Vec::new();
2179
2180        let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &configured)
2181            .unwrap()
2182            .unwrap();
2183
2184        assert_eq!(ssh.name, "builder");
2185        let output = String::from_utf8(output).unwrap();
2186        assert!(
2187            output.contains("Target podman is already configured"),
2188            "{output}"
2189        );
2190        assert!(output.contains("invalid target id"), "{output}");
2191    }
2192
2193    #[test]
2194    fn the_ssh_step_stops_asking_for_a_name_once_the_input_ends() {
2195        let aliases = vec!["podman".to_owned()];
2196        let configured = build_config_with_runtime(
2197            &[],
2198            None,
2199            Some((RuntimeKind::Podman, DEFAULT_IMAGE)),
2200            None,
2201            None,
2202        )
2203        .targets;
2204        // yes, host 1, no podman, then nothing: the default name collides, so
2205        // the question can never be answered.
2206        let mut input = b"y\n1\nn\n\n".as_slice();
2207        let mut output = Vec::new();
2208
2209        assert_eq!(
2210            prompt_ssh_target(&mut input, &mut output, &aliases, &configured).unwrap(),
2211            None
2212        );
2213        let output = String::from_utf8(output).unwrap();
2214        assert!(output.contains("Input ended; skipping"), "{output}");
2215    }
2216
2217    #[test]
2218    fn an_ssh_target_never_replaces_a_target_configured_earlier() {
2219        let ssh = SshTargetInput {
2220            name: "podman".into(),
2221            host: "builder".into(),
2222            kind: SshTargetKind::Bare {
2223                permissions: PermissionMode::Guardian,
2224            },
2225        };
2226
2227        let config = build_config_with_runtime(
2228            &[],
2229            None,
2230            Some((RuntimeKind::Podman, DEFAULT_IMAGE)),
2231            None,
2232            Some(&ssh),
2233        );
2234
2235        assert!(matches!(
2236            config.targets["podman"],
2237            TargetTemplate::LocalPodman { .. }
2238        ));
2239        assert!(matches!(
2240            config.targets["podman-2"],
2241            TargetTemplate::SshBare { .. }
2242        ));
2243        config.validate().unwrap();
2244    }
2245
2246    #[test]
2247    fn the_github_origin_is_discovered_through_the_shared_executor() {
2248        let executor = RuntimeProbeExecutor::new([ok(b"git@github.com:BrokkAi/hel.git\n")]);
2249
2250        let repository = discover_github_repository(&executor, Path::new("/work/hel")).unwrap();
2251
2252        assert_eq!(repository.source(), "BrokkAi/hel");
2253        let commands = executor.commands.borrow();
2254        assert_eq!(commands[0].program, "git");
2255        assert_eq!(
2256            commands[0].args,
2257            ["-C", "/work/hel", "remote", "get-url", "origin"]
2258        );
2259    }
2260
2261    #[test]
2262    fn no_github_origin_is_reported_when_the_probe_fails() {
2263        let failing = RuntimeProbeExecutor::new([failed(b"not a git repository")]);
2264        assert_eq!(
2265            discover_github_repository(&failing, Path::new("/work/plain")),
2266            None
2267        );
2268
2269        let missing = RuntimeProbeExecutor::new([]);
2270        assert_eq!(
2271            discover_github_repository(&missing, Path::new("/work/plain")),
2272            None
2273        );
2274    }
2275
2276    #[test]
2277    fn declining_the_aws_step_writes_no_aws_target() {
2278        let account = AwsAccount {
2279            account: "123456789012".into(),
2280            arn: "arn:aws:iam::123456789012:user/dev".into(),
2281            region: None,
2282        };
2283        let mut input = b"\n".as_slice();
2284        let mut output = Vec::new();
2285
2286        assert_eq!(
2287            prompt_aws_target(&mut input, &mut output, Some(&account)).unwrap(),
2288            None
2289        );
2290        let config = build_config_with_runtime(&[], None, None, None, None);
2291        assert!(!config.targets.contains_key(AWS_TARGET_ID));
2292    }
2293
2294    #[test]
2295    fn smoke_test_removes_the_container_after_a_failed_command() {
2296        let executor = FakeExecutor {
2297            commands: RefCell::new(vec![]),
2298            statuses: vec![0, 1, 0],
2299        };
2300        let mut output = Vec::new();
2301
2302        assert!(
2303            run_smoke_test(
2304                &mut output,
2305                &smoke_target(RuntimeKind::Podman, "ubuntu:24.04"),
2306                &executor
2307            )
2308            .is_err()
2309        );
2310        let commands = executor.commands.borrow();
2311        assert_eq!(commands.len(), 3);
2312        assert_eq!(commands[2].args[0], "rm");
2313    }
2314
2315    #[test]
2316    fn docker_smoke_test_exercises_the_managed_overlay_attachment_path() {
2317        let executor = FakeExecutor::succeeds();
2318        let mut output = Vec::new();
2319
2320        run_smoke_test(
2321            &mut output,
2322            &smoke_target(RuntimeKind::Docker, "ubuntu:24.04"),
2323            &executor,
2324        )
2325        .unwrap();
2326
2327        let commands = executor.commands.borrow();
2328        assert_eq!(commands.len(), 3);
2329        assert_eq!(commands[0].program, "sh");
2330        assert!(commands[0].args[1].contains("docker volume create"));
2331        assert!(commands[0].args[1].contains("type=overlay"));
2332        assert_eq!(commands[1].program, "docker");
2333        assert_eq!(commands[1].args[0], "exec");
2334        assert_eq!(commands[2].program, "sh");
2335        assert!(commands[2].args[1].contains("docker volume rm --force"));
2336        assert!(
2337            String::from_utf8(output)
2338                .unwrap()
2339                .contains("writable OverlayFS attachment")
2340        );
2341    }
2342
2343    #[test]
2344    fn setup_preserves_working_configuration_and_discovers_new_installations() {
2345        let directory = tempfile::tempdir().unwrap();
2346        let path = directory.path().join("config.toml");
2347        let home = DiscoveredHome {
2348            kind: HarnessKind::Codex,
2349            path: directory.path().join("codex"),
2350            authenticated: true,
2351        };
2352        let repository = GithubRepository {
2353            owner: "BrokkAi".into(),
2354            repository: "muse-acp".into(),
2355        };
2356        let mut original = build_config_with_runtimes(
2357            std::slice::from_ref(&home),
2358            Some(&repository),
2359            &[],
2360            None,
2361            None,
2362        );
2363        original.profiles.get_mut("codex").unwrap().enabled = false;
2364        original
2365            .profiles
2366            .get_mut("codex")
2367            .unwrap()
2368            .environment
2369            .insert("KEEP".into(), "custom".into());
2370        original.phone.enabled = false;
2371        original.save_to(&path).unwrap();
2372        let discovery = SetupDiscovery {
2373            homes: vec![
2374                home,
2375                DiscoveredHome {
2376                    kind: HarnessKind::Muse,
2377                    path: directory.path().join("muse"),
2378                    authenticated: true,
2379                },
2380            ],
2381            repository: Some(GithubRepository {
2382                owner: "BrokkAi".into(),
2383                repository: "mjolnir".into(),
2384            }),
2385            ..discovery_without_runtimes()
2386        };
2387        let executor = FakeExecutor::succeeds();
2388        for _ in 0..2 {
2389            let mut output = Vec::new();
2390            run_setup_dialog_with(
2391                &mut b"y\n".as_slice(),
2392                &mut output,
2393                &path,
2394                &discovery,
2395                &executor,
2396                &executor,
2397            )
2398            .unwrap();
2399            let saved = Config::load_from(&path).unwrap();
2400            assert_eq!(saved.profiles["codex"], original.profiles["codex"]);
2401            assert_eq!(
2402                saved.bundles["current-repository"],
2403                original.bundles["current-repository"]
2404            );
2405            assert_eq!(saved.targets, original.targets);
2406            assert_eq!(saved.phone, original.phone);
2407            assert_eq!(saved.profiles.len(), 2);
2408            assert_eq!(saved.profiles["muse"].kind, HarnessKind::Muse);
2409            assert_eq!(saved.bundles.len(), 2);
2410            assert_eq!(
2411                saved.bundles["mjolnir"].repositories[0].github.as_deref(),
2412                Some("BrokkAi/mjolnir")
2413            );
2414        }
2415    }
2416
2417    #[test]
2418    fn setup_target_conflict_keeps_working_target_and_can_add_alternative() {
2419        let original = build_config_with_runtimes(
2420            &[],
2421            None,
2422            &[(RuntimeKind::Podman, "original:image")],
2423            None,
2424            None,
2425        );
2426        for (answer, expected_count) in [("\n", 1), ("a\n", 2)] {
2427            let discovered = build_config_with_runtimes(
2428                &[],
2429                None,
2430                &[(RuntimeKind::Podman, "new:image")],
2431                None,
2432                None,
2433            );
2434            let mut output = Vec::new();
2435            let additions =
2436                reconcile_setup(&mut answer.as_bytes(), &mut output, &original, discovered)
2437                    .unwrap();
2438            let mut saved = original.clone();
2439            apply_setup_additions(&mut saved, &additions).unwrap();
2440            assert_eq!(saved.targets["podman"], original.targets["podman"]);
2441            assert_eq!(
2442                saved.targets.len(),
2443                original.targets.len() + expected_count - 1
2444            );
2445            assert!(
2446                String::from_utf8(output)
2447                    .unwrap()
2448                    .contains("Existing sessions will keep using it")
2449            );
2450            if expected_count == 2 {
2451                assert_eq!(
2452                    saved.targets["podman-2"],
2453                    local_runtime_target(RuntimeKind::Podman, "new:image").1
2454                );
2455            }
2456        }
2457    }
2458
2459    #[test]
2460    fn setup_reloads_concurrent_changes_and_cancellation_writes_nothing() {
2461        struct ConcurrentEdit<'a> {
2462            path: &'a Path,
2463            answer: &'a str,
2464            conflict: bool,
2465        }
2466        impl SetupPrompter for ConcurrentEdit<'_> {
2467            fn read_prompt(&mut self, _: &mut dyn Write, label: &str) -> Result<Option<String>> {
2468                assert!(label.starts_with("Write this configuration?"));
2469                Config::update_to(self.path, |config| {
2470                    config.phone.enabled = false;
2471                    if self.conflict {
2472                        config.targets.insert(
2473                            "localhost".into(),
2474                            local_runtime_target(RuntimeKind::Docker, "concurrent:image").1,
2475                        );
2476                    }
2477                    Ok(())
2478                })?;
2479                Ok(Some(self.answer.into()))
2480            }
2481        }
2482        let directory = tempfile::tempdir().unwrap();
2483        let path = directory.path().join("config.toml");
2484        let executor = FakeExecutor::succeeds();
2485        let discovery = discovery_without_runtimes();
2486        for (answer, conflict) in [("y", false), ("n", false), ("y", true)] {
2487            Config::default().save_to(&path).unwrap();
2488            let outcome = run_setup_dialog_inner(
2489                &mut ConcurrentEdit {
2490                    path: &path,
2491                    answer,
2492                    conflict,
2493                },
2494                &mut Vec::new(),
2495                &path,
2496                &discovery,
2497                &executor,
2498                &executor,
2499            );
2500            let saved = Config::load_from(&path).unwrap();
2501            assert!(!saved.phone.enabled);
2502            if conflict {
2503                assert!(
2504                    outcome
2505                        .unwrap_err()
2506                        .to_string()
2507                        .contains("changed while setup was open")
2508                );
2509                assert_eq!(
2510                    saved.targets["localhost"],
2511                    local_runtime_target(RuntimeKind::Docker, "concurrent:image").1
2512                );
2513            } else if answer == "n" {
2514                assert_eq!(outcome.unwrap(), SetupOutcome::Cancelled);
2515                assert!(saved.targets.is_empty());
2516            } else {
2517                assert_eq!(outcome.unwrap(), SetupOutcome::Written);
2518                assert!(matches!(
2519                    saved.targets["localhost"],
2520                    TargetTemplate::LocalBare
2521                ));
2522            }
2523        }
2524    }
2525
2526    #[test]
2527    fn dialog_configures_every_usable_runtime_as_a_normal_target() {
2528        let directory = tempfile::tempdir().unwrap();
2529        let config_path = directory.path().join("config.toml");
2530        let discovery = SetupDiscovery {
2531            homes: vec![DiscoveredHome {
2532                kind: HarnessKind::Codex,
2533                path: PathBuf::from("/profiles/codex"),
2534                authenticated: true,
2535            }],
2536            repository: Some(GithubRepository {
2537                owner: "BrokkAi".into(),
2538                repository: "hel".into(),
2539            }),
2540            runtimes: vec![
2541                RuntimeProbe {
2542                    kind: RuntimeKind::Podman,
2543                    usable: true,
2544                    detail: "podman version 5".into(),
2545                    remediation: None,
2546                },
2547                RuntimeProbe {
2548                    kind: RuntimeKind::Docker,
2549                    usable: true,
2550                    detail: "docker version 29".into(),
2551                    remediation: None,
2552                },
2553            ],
2554            aws: None,
2555            ssh_hosts: vec![],
2556        };
2557        let executor = FakeExecutor::succeeds();
2558        let mut input = b"\ny\n".as_slice();
2559        let mut output = Vec::new();
2560
2561        assert_eq!(
2562            run_setup_dialog_with(
2563                &mut input,
2564                &mut output,
2565                &config_path,
2566                &discovery,
2567                &executor,
2568                &executor,
2569            )
2570            .unwrap(),
2571            SetupOutcome::Written
2572        );
2573        assert!(config_path.exists());
2574        let config = Config::load_from(&config_path).unwrap();
2575        assert!(matches!(
2576            config.targets["podman"],
2577            TargetTemplate::LocalPodman { .. }
2578        ));
2579        assert!(matches!(
2580            config.targets["docker"],
2581            TargetTemplate::LocalDocker { .. }
2582        ));
2583        let smoke = executor.commands.borrow()[..3]
2584            .iter()
2585            .map(|command| command.args[0].clone())
2586            .collect::<Vec<_>>();
2587        assert_eq!(smoke, ["run", "exec", "rm"]);
2588        let commands = executor.commands.borrow();
2589        assert!(commands.len() >= 6);
2590        assert_eq!(commands[3].program, "sh");
2591        assert_eq!(commands[4].program, "docker");
2592        assert_eq!(commands[5].program, "sh");
2593        drop(commands);
2594        let output = String::from_utf8(output).unwrap();
2595        assert!(output.contains("Podman target using"), "{output}");
2596        assert!(output.contains("Docker target using"), "{output}");
2597        assert!(!output.contains("Recommended runtime"), "{output}");
2598        assert!(!output.contains("Runtime ("), "{output}");
2599        assert!(output.ends_with("Press n to start your first session.\n"));
2600    }
2601
2602    #[test]
2603    fn a_failed_smoke_test_becomes_a_fixable_line_in_the_closing_report() {
2604        let directory = tempfile::tempdir().unwrap();
2605        let config_path = directory.path().join("config.toml");
2606        let discovery = SetupDiscovery {
2607            runtimes: vec![RuntimeProbe {
2608                kind: RuntimeKind::Podman,
2609                usable: true,
2610                detail: "podman version 5".into(),
2611                remediation: None,
2612            }],
2613            ..discovery_without_runtimes()
2614        };
2615        // Create the container, fail the command inside it, remove it.
2616        let executor = FakeExecutor {
2617            commands: RefCell::new(vec![]),
2618            statuses: vec![0, 1, 0],
2619        };
2620        let mut input = b"\ny\n".as_slice();
2621        let mut output = Vec::new();
2622
2623        let outcome = run_setup_dialog_with(
2624            &mut input,
2625            &mut output,
2626            &config_path,
2627            &discovery,
2628            &executor,
2629            &executor,
2630        )
2631        .unwrap();
2632
2633        assert_eq!(outcome, SetupOutcome::Written);
2634        assert!(config_path.exists());
2635        let output = String::from_utf8(output).unwrap();
2636        assert!(output.contains("fixable Podman smoke test"), "{output}");
2637        assert!(
2638            output.contains("remediation: Fix the configured image or the Podman runtime"),
2639            "{output}"
2640        );
2641        // The report the user was promised still runs, and still ends with the
2642        // instruction to apply the remediations it just listed.
2643        assert!(
2644            output.contains("Running `mj doctor` checks on the new config..."),
2645            "{output}"
2646        );
2647        assert!(
2648            output.contains("Apply the remediations above, then rerun `mj doctor`."),
2649            "{output}"
2650        );
2651        assert!(
2652            output.ends_with("Press n to start your first session.\n"),
2653            "{output}"
2654        );
2655    }
2656
2657    #[test]
2658    fn setup_finishes_with_the_standard_doctor_report_for_the_config_it_wrote() {
2659        let directory = tempfile::tempdir().unwrap();
2660        let config_path = directory.path().join("config.toml");
2661        let discovery = SetupDiscovery {
2662            homes: vec![DiscoveredHome {
2663                kind: HarnessKind::Codex,
2664                path: directory.path().join("missing-codex-home"),
2665                authenticated: false,
2666            }],
2667            ..discovery_without_runtimes()
2668        };
2669        let executor = FakeExecutor::succeeds();
2670        let mut input = b"y\n".as_slice();
2671        let mut output = Vec::new();
2672
2673        run_setup_dialog_with(
2674            &mut input,
2675            &mut output,
2676            &config_path,
2677            &discovery,
2678            &executor,
2679            &executor,
2680        )
2681        .unwrap();
2682
2683        let output = String::from_utf8(output).unwrap();
2684        // The report is doctor's own rendering: a status-prefixed line per
2685        // check, plus the remediation doctor would print for the missing home.
2686        assert!(
2687            output.contains(&format!(
2688                "ready Mjolnir configuration: {} is valid",
2689                config_path.display()
2690            )),
2691            "{output}"
2692        );
2693        assert!(output.contains("fixable Harness profile codex"), "{output}");
2694        assert!(
2695            output.contains("  remediation: Run `mj login --profile codex`"),
2696            "{output}"
2697        );
2698        assert!(
2699            output.contains("Apply the remediations above, then rerun `mj doctor`."),
2700            "{output}"
2701        );
2702    }
2703
2704    #[test]
2705    fn dialog_configures_raw_localhost_without_a_container_runtime() {
2706        let directory = tempfile::tempdir().unwrap();
2707        let config_path = directory.path().join("config.toml");
2708        let discovery = SetupDiscovery {
2709            homes: vec![DiscoveredHome {
2710                kind: HarnessKind::Kimi,
2711                path: PathBuf::from("/profiles/kimi"),
2712                authenticated: true,
2713            }],
2714            repository: None,
2715            runtimes: vec![RuntimeProbe {
2716                kind: RuntimeKind::Podman,
2717                usable: false,
2718                detail: "not installed".into(),
2719                remediation: Some("Install Podman.".into()),
2720            }],
2721            aws: None,
2722            ssh_hosts: vec![],
2723        };
2724        let executor = FakeExecutor::succeeds();
2725        let mut input = b"y\n".as_slice();
2726        let mut output = Vec::new();
2727
2728        assert_eq!(
2729            run_setup_dialog_with(
2730                &mut input,
2731                &mut output,
2732                &config_path,
2733                &discovery,
2734                &executor,
2735                &executor,
2736            )
2737            .unwrap(),
2738            SetupOutcome::Written
2739        );
2740        let config = Config::load_from(&config_path).unwrap();
2741        assert!(matches!(
2742            config.targets["localhost"],
2743            TargetTemplate::LocalBare
2744        ));
2745        // No smoke test runs without a runtime; the trailing commands belong to
2746        // the doctor report.
2747        assert!(
2748            executor
2749                .commands
2750                .borrow()
2751                .iter()
2752                .all(|command| command.program != "podman" || command.args[0] != "run")
2753        );
2754        let output = String::from_utf8(output).unwrap();
2755        assert!(output.contains("DANGER"));
2756        assert!(output.contains("has no guardian approval mode"));
2757        assert!(output.contains("raw localhost will still be configured"));
2758    }
2759
2760    #[test]
2761    fn discovered_homes_warn_for_harnesses_without_guardian_approvals() {
2762        let warning = |kind: HarnessKind| {
2763            let mut output = Vec::new();
2764            write_discovered_homes(
2765                &mut output,
2766                &[DiscoveredHome {
2767                    kind,
2768                    path: PathBuf::from("/profiles/harness"),
2769                    authenticated: true,
2770                }],
2771            )
2772            .unwrap();
2773            String::from_utf8(output).unwrap()
2774        };
2775
2776        for kind in [HarnessKind::Kimi, HarnessKind::Deepseek] {
2777            let output = warning(kind);
2778            assert!(output.contains("DANGER"), "{kind:?}: {output}");
2779            assert!(
2780                output.contains("has no guardian approval mode"),
2781                "{kind:?}: {output}"
2782            );
2783            assert!(output.contains("raw, unsandboxed target"), "{output}");
2784        }
2785
2786        for kind in [HarnessKind::Codex, HarnessKind::Claude, HarnessKind::Grok] {
2787            assert!(!warning(kind).contains("DANGER"), "{kind:?}");
2788        }
2789    }
2790}