Skip to main content

spool/bootstrap/
orchestrator.rs

1//! Bootstrap orchestration — runs the full first-run / upgrade flow.
2//!
3//! Called from the Tauri desktop app on startup. Idempotent: safe to call
4//! every launch. Skips heavy steps when already complete.
5
6use anyhow::{Context, Result};
7use std::path::Path;
8
9use super::auto_configure::{self, AutoConfigureReport};
10use super::layout::SpoolLayout;
11use super::path_config::{self, PathConfigReport};
12use super::release::{ReleaseReport, release_binaries};
13use super::state::{BootstrapState, ServiceVersion};
14
15/// Result of `run_bootstrap`. Tauri layer can surface progress to the user.
16#[derive(Debug, Clone, Default)]
17pub struct BootstrapReport {
18    pub layout_created: bool,
19    pub release: Option<ReleaseReport>,
20    pub auto_configure: Option<AutoConfigureReport>,
21    pub path_config: Option<PathConfigReport>,
22    pub state_after: BootstrapState,
23    pub messages: Vec<String>,
24}
25
26/// Top-level bootstrap entry point.
27///
28/// - `bundled_binaries_dir`: where the Tauri app bundle stores the embedded
29///   binaries (resolved by the desktop layer via `app.path().resource_dir()`).
30/// - Returns a [`BootstrapReport`] describing what changed.
31pub fn run_bootstrap(bundled_binaries_dir: &Path) -> Result<BootstrapReport> {
32    let layout = SpoolLayout::resolve()?;
33    run_bootstrap_with_layout(&layout, bundled_binaries_dir)
34}
35
36/// Bootstrap with an explicit layout. Useful for tests.
37pub fn run_bootstrap_with_layout(
38    layout: &SpoolLayout,
39    bundled_binaries_dir: &Path,
40) -> Result<BootstrapReport> {
41    let mut report = BootstrapReport::default();
42
43    // Step 1: ensure directory layout
44    layout
45        .ensure_dirs()
46        .context("creating spool directory layout")?;
47    report.layout_created = true;
48    report
49        .messages
50        .push(format!("layout ready at {}", layout.root().display()));
51
52    // Step 2: load existing state
53    let mut state =
54        BootstrapState::load(&layout.version_file()).context("loading bootstrap state")?;
55
56    let current_version = ServiceVersion::current();
57    let needs_release = match &state.service {
58        Some(installed) => installed.version != current_version.version,
59        None => true,
60    };
61
62    // Step 3: release binaries if needed
63    let binaries_ready;
64    if needs_release && bundled_binaries_dir.is_dir() {
65        let release = release_binaries(bundled_binaries_dir, &layout.bin_dir(), needs_release)
66            .context("releasing bundled binaries")?;
67        report.messages.push(format!(
68            "released {} binaries (skipped {})",
69            release.copied.len(),
70            release.skipped.len()
71        ));
72        binaries_ready = !release.copied.is_empty() || layout.binary_path("spool-mcp").exists();
73        report.release = Some(release);
74        state.service = Some(current_version);
75    } else if !bundled_binaries_dir.is_dir() {
76        report.messages.push(format!(
77            "bundled binaries directory missing — skipping release: {}",
78            bundled_binaries_dir.display()
79        ));
80        binaries_ready = layout.binary_path("spool-mcp").exists();
81    } else {
82        report
83            .messages
84            .push("binaries already up to date".to_string());
85        binaries_ready = layout.binary_path("spool-mcp").exists();
86    }
87
88    // Step 4: auto-configure detected AI tools (Claude/Codex/Cursor/OpenCode)
89    if binaries_ready {
90        let cfg_report = auto_configure::auto_configure_clients(layout, false);
91        if cfg_report.any_registered {
92            state.mcp_registered = true;
93        }
94        if cfg_report.hooks_installed {
95            state.hooks_installed = true;
96        }
97        report.messages.push(format!(
98            "auto-configured {} client(s); MCP registered: {}",
99            cfg_report.clients.iter().filter(|c| c.installed).count(),
100            cfg_report.any_registered,
101        ));
102        report.auto_configure = Some(cfg_report);
103    } else {
104        report
105            .messages
106            .push("skipping auto-configure: spool-mcp binary not available".to_string());
107    }
108
109    // Step 5: configure shell PATH (best-effort)
110    if binaries_ready {
111        match path_config::configure_path(&layout.bin_dir()) {
112            Ok(path_report) => {
113                if path_report.configured {
114                    state.path_configured = true;
115                }
116                report.messages.push(format!(
117                    "PATH configured: {} new file(s), {} already present",
118                    path_report.modified_files.len(),
119                    path_report.already_configured_files.len(),
120                ));
121                report.path_config = Some(path_report);
122            }
123            Err(err) => {
124                report
125                    .messages
126                    .push(format!("PATH configuration failed: {err:#}"));
127            }
128        }
129    }
130
131    // Step 6: persist state
132    state.gui_version = Some(env!("CARGO_PKG_VERSION").to_string());
133    state
134        .save(&layout.version_file())
135        .context("persisting bootstrap state")?;
136    report.state_after = state;
137
138    Ok(report)
139}
140
141/// Quick check used by the desktop app to decide whether to show the
142/// onboarding UI on startup.
143pub fn is_first_run() -> bool {
144    let Ok(layout) = SpoolLayout::resolve() else {
145        return true;
146    };
147    let Ok(state) = BootstrapState::load(&layout.version_file()) else {
148        return true;
149    };
150    !state.is_bootstrapped()
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use std::fs;
157    use tempfile::tempdir;
158
159    fn write_fake_binary(dir: &Path, name: &str) {
160        let exe_name = if cfg!(windows) {
161            format!("{name}.exe")
162        } else {
163            name.to_string()
164        };
165        fs::write(dir.join(&exe_name), format!("fake {}", name)).unwrap();
166    }
167
168    #[test]
169    fn bootstrap_should_create_layout_and_release_binaries() {
170        let temp = tempdir().unwrap();
171        let layout = SpoolLayout::from_root(temp.path().join(".spool"));
172
173        let bundled = temp.path().join("bundled");
174        fs::create_dir_all(&bundled).unwrap();
175        write_fake_binary(&bundled, "spool");
176        write_fake_binary(&bundled, "spool-mcp");
177        write_fake_binary(&bundled, "spool-daemon");
178
179        let report = run_bootstrap_with_layout(&layout, &bundled).unwrap();
180
181        assert!(report.layout_created);
182        assert!(layout.bin_dir().is_dir());
183        assert!(layout.data_dir().is_dir());
184        assert!(layout.plugins_dir().is_dir());
185
186        let release = report.release.expect("should have released");
187        assert_eq!(release.copied.len(), 3);
188        assert!(report.state_after.is_bootstrapped());
189    }
190
191    #[test]
192    fn bootstrap_should_be_idempotent() {
193        let temp = tempdir().unwrap();
194        let layout = SpoolLayout::from_root(temp.path().join(".spool"));
195        let bundled = temp.path().join("bundled");
196        fs::create_dir_all(&bundled).unwrap();
197        write_fake_binary(&bundled, "spool");
198
199        run_bootstrap_with_layout(&layout, &bundled).unwrap();
200        let report2 = run_bootstrap_with_layout(&layout, &bundled).unwrap();
201
202        let release2 = report2.release;
203        // Second run sees same version → no release at all
204        assert!(release2.is_none() || release2.unwrap().copied.is_empty());
205    }
206
207    #[test]
208    fn bootstrap_should_skip_release_when_bundled_dir_missing() {
209        let temp = tempdir().unwrap();
210        let layout = SpoolLayout::from_root(temp.path().join(".spool"));
211        let bundled = temp.path().join("nonexistent");
212
213        let report = run_bootstrap_with_layout(&layout, &bundled).unwrap();
214        assert!(report.release.is_none());
215        assert!(
216            report
217                .messages
218                .iter()
219                .any(|m| m.contains("bundled binaries directory missing"))
220        );
221    }
222}