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
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
146pub 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 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;