Skip to main content

atomcode_core/plugin/
bootstrap.rs

1//! First-startup install + post-upgrade refresh hooks for the plugin
2//! marketplace layer.
3//!
4//! Two distinct user journeys land here:
5//!
6//! 1. **Fresh install** — atomcode runs for the first time on a host
7//!    that has never run it. The marker file
8//!    `$ATOMCODE_HOME/.plugin_bootstrap_v1` does not exist. We `git clone`
9//!    the default `atomcode-skills` marketplace and touch the marker.
10//!    Failure (no network, no git on PATH, upstream down) is logged
11//!    and swallowed — startup proceeds without skills.
12//!
13//! 2. **Existing user, just upgraded** — `apply_pending_upgrade`
14//!    re-exec'd into the new binary and set `ATOMCODE_UPGRADED_FROM`.
15//!    We `git pull --ff-only` every installed marketplace so the
16//!    skills track the new binary. Same swallowed-failure semantics.
17//!
18//! The marker file makes (1) a one-time event. If the user later runs
19//! `/plugin uninstall atomcode-skills`, the marker stays and we
20//! respect their intent — no re-install on subsequent startups. To
21//! force a re-bootstrap, the user can `rm $ATOMCODE_HOME/.plugin_bootstrap_v1`.
22//!
23//! Both functions are best-effort and never propagate errors —
24//! atomcode must remain usable on offline machines, in air-gapped
25//! corporate environments, on systems without git, etc.
26
27use crate::config::Config;
28
29use super::marketplace::{add_marketplace, list_marketplaces, update_marketplace};
30
31/// Public git URL for the default skills marketplace. The plugin
32/// installer dispatches on the SOURCE field (the URL we cloned from),
33/// so this string is the identity of the "default skills" entry.
34pub const DEFAULT_SKILLS_URL: &str =
35    "https://atomgit.com/atomgit_atomcode/atomcode-skills.git";
36
37/// Versioned bootstrap marker. Bump the `_v1` suffix when introducing
38/// a new bootstrap step (e.g. a second default marketplace, or a
39/// post-install migration) so existing users opt into the new run.
40const BOOTSTRAP_MARKER_FILENAME: &str = ".plugin_bootstrap_v1";
41
42/// Env var that the upgrade path (`self_update::apply_pending_upgrade`
43/// → `re_exec_self`) sets on the new binary so the new session knows
44/// it was launched as the result of a version upgrade. We read it
45/// non-destructively here — the TUIX event loop owns the eventual
46/// `remove_var` so the welcome-screen confirmation still fires.
47const UPGRADED_FROM_ENV: &str = "ATOMCODE_UPGRADED_FROM";
48
49/// Entry point for both Plan A (auto-install default skills) and
50/// Plan B (auto-update marketplaces after upgrade). Call once at
51/// startup AFTER `Config::load` and AFTER any pending self-upgrade has
52/// re-exec'd. Synchronous — runs `git` subprocesses inline; budget
53/// roughly 1-3 s on a warm path, longer on first install.
54pub fn run_startup_hooks(config: &Config) {
55    if config.plugin.auto_install_default_skills {
56        maybe_install_default_skills();
57    }
58    let upgraded = std::env::var(UPGRADED_FROM_ENV).is_ok();
59    if upgraded && config.plugin.auto_update_marketplaces {
60        refresh_installed_marketplaces();
61    }
62}
63
64fn bootstrap_marker_path() -> std::path::PathBuf {
65    // Lives directly under `~/.atomcode/` (the canonical config dir),
66    // not nested under `plugins/` — it's a per-user run-state flag,
67    // not a plugin asset. Same neighbourhood as
68    // `.telemetry_notice_shown`.
69    Config::config_dir().join(BOOTSTRAP_MARKER_FILENAME)
70}
71
72fn marker_exists() -> bool {
73    bootstrap_marker_path().exists()
74}
75
76fn touch_marker() {
77    let path = bootstrap_marker_path();
78    if let Some(parent) = path.parent() {
79        let _ = std::fs::create_dir_all(parent);
80    }
81    let _ = std::fs::write(&path, b"");
82}
83
84/// Plan A: clone the default skills marketplace into
85/// `$ATOMCODE_HOME/plugins/marketplaces/atomcode-skills/` if (a) the
86/// bootstrap marker isn't there yet AND (b) the marketplace isn't
87/// already installed. After this attempt — successful or not — the
88/// marker is written so the next startup doesn't try again.
89fn maybe_install_default_skills() {
90    if marker_exists() {
91        return;
92    }
93
94    // Marketplace already present from a prior manual `/plugin install`?
95    // Honour it. Just write the marker so we don't try to clone over
96    // it next startup.
97    let already_installed = list_marketplaces()
98        .map(|list| {
99            list.iter()
100                .any(|m| m.source.eq_ignore_ascii_case(DEFAULT_SKILLS_URL))
101        })
102        .unwrap_or(false);
103    if already_installed {
104        touch_marker();
105        return;
106    }
107
108    match add_marketplace(DEFAULT_SKILLS_URL) {
109        Ok(info) => {
110            eprintln!(
111                "✓ Auto-installed default skills marketplace `{}` (commit {}).",
112                info.name,
113                short_commit(&info.git_commit)
114            );
115        }
116        Err(e) => {
117            eprintln!(
118                "⚠ Auto-install of default skills marketplace failed (non-fatal): {e}\n  \
119                 Run `/plugin install {DEFAULT_SKILLS_URL}` manually when ready."
120            );
121        }
122    }
123
124    // Mark the bootstrap as attempted. Even on failure we don't want
125    // to retry on every launch — that turns into a flapping network
126    // probe. The user can delete the marker to force a retry.
127    touch_marker();
128}
129
130/// Plan B: best-effort `git pull --ff-only` on every installed
131/// marketplace. Only runs when called from a session that was launched
132/// by `apply_pending_upgrade` (caller gates on `ATOMCODE_UPGRADED_FROM`).
133fn refresh_installed_marketplaces() {
134    let list = match list_marketplaces() {
135        Ok(l) => l,
136        Err(e) => {
137            eprintln!("⚠ Could not enumerate marketplaces for auto-update: {e}");
138            return;
139        }
140    };
141    if list.is_empty() {
142        return;
143    }
144    for entry in list {
145        match update_marketplace(&entry.name) {
146            Ok(info) => {
147                if info.git_commit != entry.git_commit {
148                    eprintln!(
149                        "✓ Updated marketplace `{}` ({} → {}).",
150                        entry.name,
151                        short_commit(&entry.git_commit),
152                        short_commit(&info.git_commit)
153                    );
154                }
155            }
156            Err(e) => {
157                eprintln!(
158                    "⚠ Auto-update of marketplace `{}` failed (non-fatal): {e}",
159                    entry.name
160                );
161            }
162        }
163    }
164}
165
166fn short_commit(sha: &str) -> &str {
167    if sha.len() >= 7 {
168        &sha[..7]
169    } else {
170        sha
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn short_commit_truncates_long_shas() {
180        assert_eq!(short_commit("0123456789abcdef"), "0123456");
181    }
182
183    #[test]
184    fn short_commit_passes_through_short_input() {
185        assert_eq!(short_commit("abc"), "abc");
186        assert_eq!(short_commit(""), "");
187    }
188
189    #[test]
190    fn marker_path_uses_versioned_filename() {
191        // We don't assert the exact `~/.atomcode/...` location (depends
192        // on $HOME / env), but the suffix should be the versioned
193        // marker name so future bootstrap-v2 introductions don't
194        // accidentally overwrite v1's marker.
195        let p = bootstrap_marker_path();
196        assert!(
197            p.to_string_lossy().ends_with(BOOTSTRAP_MARKER_FILENAME),
198            "marker path must end with versioned filename, got {:?}",
199            p
200        );
201    }
202}