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}