1use 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
46pub 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 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 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
127pub 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 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 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
187pub 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 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;