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
127fn acquire_setup_lock(path: &Path, cancelled: &dyn Fn() -> bool) -> Result<File> {
128    let mut options = OpenOptions::new();
129    options.create(true).read(true).write(true);
130    #[cfg(unix)]
131    {
132        use std::os::unix::fs::OpenOptionsExt;
133        options.mode(0o600);
134    }
135    let file = options.open(path).context("open setup lock")?;
136    loop {
137        ensure!(!cancelled(), "setup cancelled");
138        match file.try_lock() {
139            Ok(()) => return Ok(file),
140            Err(std::fs::TryLockError::WouldBlock) => std::thread::sleep(Duration::from_millis(25)),
141            Err(std::fs::TryLockError::Error(error)) => return Err(error).context("lock setup"),
142        }
143    }
144}
145
146/// Welcome displays errors and concrete remedies, without successful checks
147/// or warnings about optional built-in runtimes and background image downloads.
148pub fn actionable_errors(checks: &[DoctorCheck]) -> Vec<String> {
149    checks
150        .iter()
151        .filter(|check| check.status == CheckStatus::Fixable)
152        .map(|check| {
153            match &check.remediation {
154                // The footer has one line: make the remedy visible before long
155                // diagnostic context, while retaining that context in the log.
156                Some(remediation) => format!("{}: {}\n{}", check.title, remediation, check.detail),
157                None => format!("{}: {}", check.title, check.detail),
158            }
159        })
160        .collect()
161}
162
163pub fn run_setup_command(config_path: &Path) -> Result<()> {
164    let probes = probe_executor();
165    let report = run_setup(config_path, &data_dir(), true, &probes, &|| false, || {})?
166        .context("explicit setup was skipped")?;
167    let mut output = io::stdout().lock();
168    writeln!(output, "Welcome to Mjolnir")?;
169    for line in report.summary() {
170        writeln!(output, "{line}")?;
171    }
172    let checks = run_with_config_path(
173        config_path,
174        &probes,
175        current_apple_platform(&probes),
176        DoctorOptions { smoke: false },
177    );
178    for error in actionable_errors(&checks) {
179        writeln!(output, "\n{error}")?;
180    }
181    writeln!(
182        output,
183        "\nRun `mj` and press {} to create a session.",
184        Config::load_from(config_path)?
185            .keybinds()
186            .labels(mj_core::config::KeyAction::NewSession)
187            .first()
188            .map(String::as_str)
189            .unwrap_or("the New session command")
190    )?;
191    Ok(())
192}
193
194#[cfg(test)]
195mod tests;