Skip to main content

mj_controller/setup/
startup.rs

1//! One serialized setup transaction per instance, including interruption recovery.
2
3use std::fs::{File, OpenOptions};
4use std::io::{self, Write};
5use std::path::Path;
6use std::time::Duration;
7
8use anyhow::{Context, Result, bail, ensure};
9
10use super::{
11    DiscoveredHome, GithubRepository, build_config, discover_github_repository, discover_profiles,
12};
13use crate::doctor::{
14    CheckStatus, DoctorCheck, DoctorOptions, current_apple_platform, probe_executor,
15    run_with_config_path,
16};
17use crate::targets::CommandExecutor;
18use mj_core::config::{Config, HarnessKind, atomic_write, data_dir};
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct SetupReport {
22    pub agents: Vec<HarnessKind>,
23    pub repository: Option<String>,
24}
25
26impl SetupReport {
27    pub fn summary(&self) -> Vec<String> {
28        let mut lines = Vec::new();
29        if !self.agents.is_empty() {
30            lines.push(format!(
31                "Agents: {}.",
32                self.agents
33                    .iter()
34                    .map(|kind| kind.display_name())
35                    .collect::<Vec<_>>()
36                    .join(", ")
37            ));
38        }
39        if let Some(repository) = &self.repository {
40            lines.push(format!("Project: {repository}."));
41        }
42        lines
43    }
44}
45
46/// Returns `None` when automatic setup belongs to an existing installation.
47/// The accepted callback runs under the setup lock, before discovery starts.
48pub fn run_setup(
49    config_path: &Path,
50    instance_data: &Path,
51    force: bool,
52    executor: &impl CommandExecutor,
53    cancelled: &dyn Fn() -> bool,
54    accepted: impl FnOnce(),
55) -> Result<Option<SetupReport>> {
56    run_setup_with_discovery(
57        config_path,
58        instance_data,
59        force,
60        cancelled,
61        accepted,
62        || {
63            let homes = discover_profiles(executor);
64            let cwd = std::env::current_dir().context("read current project directory")?;
65            let repository = discover_github_repository(executor, &cwd);
66            Ok((homes, repository))
67        },
68    )
69}
70
71fn run_setup_with_discovery(
72    config_path: &Path,
73    instance_data: &Path,
74    force: bool,
75    cancelled: &dyn Fn() -> bool,
76    accepted: impl FnOnce(),
77    discover: impl FnOnce() -> Result<(Vec<DiscoveredHome>, Option<GithubRepository>)>,
78) -> Result<Option<SetupReport>> {
79    std::fs::create_dir_all(instance_data).context("create setup state directory")?;
80    let _owner = acquire_setup_lock(&instance_data.join("setup.lock"), cancelled)?;
81    let marker = instance_data.join("setup-state");
82    let state = match std::fs::read(&marker) {
83        Ok(state) => Some(state),
84        Err(error) if error.kind() == io::ErrorKind::NotFound => None,
85        Err(error) => return Err(error).context("read setup state"),
86    };
87    let pending = match state.as_deref() {
88        None | Some(b"complete\n") => false,
89        Some(b"pending\n") => true,
90        Some(_) => bail!("unrecognized setup state in {}", marker.display()),
91    };
92    let existing = Config::load_from(config_path)?;
93    if !force
94        && (state.as_deref() == Some(b"complete\n") || (!pending && !existing.is_unconfigured()))
95    {
96        return Ok(None);
97    }
98    ensure!(!cancelled(), "setup cancelled");
99    accepted();
100    // Persist intent first: if the process dies after saving the config, the
101    // next launch must finish setup rather than misclassify it as an old install.
102    atomic_write(&marker, b"pending\n")?;
103    let (homes, repository) = discover()?;
104    ensure!(!cancelled(), "setup cancelled");
105    let discovered = build_config(&homes, repository.as_ref());
106    Config::update_to(config_path, |latest| {
107        // Choose identifiers against the latest file under its transaction lock.
108        // This preserves simultaneous Settings edits and makes reruns additive.
109        let additions = latest.setup_additions(&discovered);
110        latest.profiles.extend(additions.profiles);
111        latest.bundles.extend(additions.bundles);
112        latest.targets.extend(additions.targets);
113        Ok(())
114    })?;
115    atomic_write(&marker, b"complete\n")?;
116    Ok(Some(SetupReport {
117        agents: homes
118            .iter()
119            .map(|home| home.kind)
120            .collect::<std::collections::BTreeSet<_>>()
121            .into_iter()
122            .collect(),
123        repository: repository.map(|repository| repository.source()),
124    }))
125}
126
127/// Adds a profile for every agent installed since the configuration was last
128/// written, the same additive merge as Settings' Detect profiles. Each launch
129/// of an existing installation runs this, so a newly installed agent appears
130/// without a trip to Settings. This runs on every launch by the user's
131/// decision; a removed profile for an installed agent returns, exactly as it
132/// does from Settings' Detect profiles, and disabling the profile is how a
133/// user stops using an agent. Returns the identifiers it added.
134pub fn add_detected_profiles(
135    config_path: &Path,
136    executor: &impl CommandExecutor,
137    cancelled: &dyn Fn() -> bool,
138) -> Result<Vec<String>> {
139    add_profiles_for(config_path, &discover_profiles(executor), cancelled)
140}
141
142fn add_profiles_for(
143    config_path: &Path,
144    homes: &[DiscoveredHome],
145    cancelled: &dyn Fn() -> bool,
146) -> Result<Vec<String>> {
147    let discovered = super::profiles_config(homes);
148    ensure!(!cancelled(), "profile detection cancelled");
149    // Leave the file untouched on the common launch that finds nothing new.
150    if Config::load_from(config_path)?
151        .setup_additions(&discovered)
152        .profiles
153        .is_empty()
154    {
155        return Ok(Vec::new());
156    }
157    let (_, added) = Config::update_to(config_path, |latest| {
158        // Choose identifiers against the latest file under its lock, so a
159        // concurrent Settings save is preserved.
160        let additions = latest.setup_additions(&discovered).profiles;
161        let added = additions.keys().cloned().collect();
162        latest.profiles.extend(additions);
163        Ok(added)
164    })?;
165    Ok(added)
166}
167
168fn acquire_setup_lock(path: &Path, cancelled: &dyn Fn() -> bool) -> Result<File> {
169    let mut options = OpenOptions::new();
170    options.create(true).read(true).write(true);
171    #[cfg(unix)]
172    {
173        use std::os::unix::fs::OpenOptionsExt;
174        options.mode(0o600);
175    }
176    let file = options.open(path).context("open setup lock")?;
177    loop {
178        ensure!(!cancelled(), "setup cancelled");
179        match file.try_lock() {
180            Ok(()) => return Ok(file),
181            Err(std::fs::TryLockError::WouldBlock) => std::thread::sleep(Duration::from_millis(25)),
182            Err(std::fs::TryLockError::Error(error)) => return Err(error).context("lock setup"),
183        }
184    }
185}
186
187/// Welcome displays errors and concrete remedies, without successful checks
188/// or warnings about optional built-in runtimes and background image downloads.
189pub fn actionable_errors(checks: &[DoctorCheck]) -> Vec<String> {
190    checks
191        .iter()
192        .filter(|check| check.status == CheckStatus::Fixable)
193        .map(|check| {
194            match &check.remediation {
195                // The footer has one line: make the remedy visible before long
196                // diagnostic context, while retaining that context in the log.
197                Some(remediation) => format!("{}: {}\n{}", check.title, remediation, check.detail),
198                None => format!("{}: {}", check.title, check.detail),
199            }
200        })
201        .collect()
202}
203
204pub fn run_setup_command(config_path: &Path) -> Result<()> {
205    let probes = probe_executor();
206    let report = run_setup(config_path, &data_dir(), true, &probes, &|| false, || {})?
207        .context("explicit setup was skipped")?;
208    let mut output = io::stdout().lock();
209    writeln!(output, "Welcome to Mjolnir")?;
210    for line in report.summary() {
211        writeln!(output, "{line}")?;
212    }
213    let checks = run_with_config_path(
214        config_path,
215        &probes,
216        current_apple_platform(&probes),
217        DoctorOptions { smoke: false },
218    );
219    for error in actionable_errors(&checks) {
220        writeln!(output, "\n{error}")?;
221    }
222    writeln!(
223        output,
224        "\nRun `mj` and press {} to create a session.",
225        Config::load_from(config_path)?
226            .keybinds()
227            .labels(mj_core::config::KeyAction::NewSession)
228            .first()
229            .map(String::as_str)
230            .unwrap_or("the New session command")
231    )?;
232    Ok(())
233}
234
235#[cfg(test)]
236mod tests;