systemprompt_cli/commands/core/services/
refresh.rs1use anyhow::{Context, Result};
7use clap::Args;
8use serde::Serialize;
9use systemprompt_config::{ProfileBootstrap, SecretsBootstrap};
10use systemprompt_loader::ServicesSourceBootstrap;
11use systemprompt_loader::bundle::source::{AnyFetcher, BundleFetcher};
12use systemprompt_loader::bundle::{BundleCache, cache_root};
13use systemprompt_models::Profile;
14use systemprompt_models::services::bundle::ServicesBundleState;
15
16use super::reconcile::{ReconcileRow, reconcile_after_swap};
17use crate::context::CommandContext;
18use crate::shared::{CommandOutput, render_result};
19
20pub const EXIT_CHANGED: i32 = 3;
21
22#[derive(Debug, Clone, Copy, Args)]
23pub struct RefreshArgs {
24 #[arg(
25 long,
26 help = "Only compare remote digests; fetch nothing and swap nothing"
27 )]
28 pub check: bool,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum RefreshOutcome {
33 Unchanged,
34 Changed,
35}
36
37#[must_use]
38pub const fn exit_code_for(outcome: RefreshOutcome) -> i32 {
39 match outcome {
40 RefreshOutcome::Unchanged => 0,
41 RefreshOutcome::Changed => EXIT_CHANGED,
42 }
43}
44
45#[derive(Debug, Serialize)]
46pub struct SourceRow {
47 pub name: String,
48 pub previous_digest: String,
49 pub new_digest: String,
50 pub version: String,
51 pub changed: bool,
52}
53
54pub async fn execute(args: &RefreshArgs, ctx: &CommandContext) -> Result<()> {
55 let profile = ProfileBootstrap::get().context("Failed to get profile")?;
56 let cache = BundleCache::new(cache_root(profile));
57 let before = cache.read_state();
58
59 let (rows, outcome, reconciled) = if args.check {
60 let (rows, outcome) = check_sources(profile, &before).await?;
61 (rows, outcome, Vec::new())
62 } else {
63 swap_sources(profile, &cache, &before, ctx).await?
64 };
65
66 let title = if args.check {
67 "Services Sources (check)"
68 } else {
69 "Services Sources"
70 };
71 render_result(
72 &CommandOutput::table_of(
73 vec![
74 "name",
75 "previous_digest",
76 "new_digest",
77 "version",
78 "changed",
79 ],
80 &rows,
81 )
82 .with_title(title),
83 &ctx.cli,
84 );
85
86 if !reconciled.is_empty() {
87 render_result(
88 &CommandOutput::table_of(
89 vec![
90 "bundle",
91 "inserted",
92 "updated",
93 "deleted",
94 "protected",
95 "inert_role_rules",
96 ],
97 &reconciled,
98 )
99 .with_title("Access Rules Reconciled"),
100 &ctx.cli,
101 );
102 }
103
104 let code = exit_code_for(outcome);
107 if code != 0 {
108 #[expect(
109 clippy::exit,
110 reason = "the documented exit code is this command's contract with a supervisor \
111 script; anyhow can only produce 1"
112 )]
113 std::process::exit(code);
114 }
115 Ok(())
116}
117
118async fn check_sources(
119 profile: &Profile,
120 before: &ServicesBundleState,
121) -> Result<(Vec<SourceRow>, RefreshOutcome)> {
122 let client = reqwest::Client::builder()
123 .redirect(reqwest::redirect::Policy::none())
124 .build()
125 .context("Failed to build an HTTP client")?;
126
127 let mut rows = Vec::new();
128 for source in &profile.services.sources {
129 let auth = source.auth_secret().and_then(lookup_secret);
130 let fetcher = AnyFetcher::from_source(source, auth, &client)
131 .with_context(|| format!("Source {} is not usable", source.name))?;
132 let remote = fetcher
133 .head()
134 .await
135 .with_context(|| format!("Source {} could not be reached", source.name))?;
136 let known = before.sources.get(&source.name);
137 let previous = known.map_or_else(String::new, |s| s.digest.clone());
138 rows.push(SourceRow {
139 name: source.name.clone(),
140 changed: remote.is_unknown() || previous != remote.digest,
141 new_digest: remote.digest,
142 previous_digest: previous,
143 version: known.map_or_else(String::new, |s| s.version.clone()),
144 });
145 }
146 let outcome = outcome_for(&rows);
147 Ok((rows, outcome))
148}
149
150#[must_use]
151pub fn outcome_for(rows: &[SourceRow]) -> RefreshOutcome {
152 if rows.iter().any(|row| row.changed) {
153 RefreshOutcome::Changed
154 } else {
155 RefreshOutcome::Unchanged
156 }
157}
158
159async fn swap_sources(
160 profile: &Profile,
161 cache: &BundleCache,
162 before: &ServicesBundleState,
163 ctx: &CommandContext,
164) -> Result<(Vec<SourceRow>, RefreshOutcome, Vec<ReconcileRow>)> {
165 let root = ServicesSourceBootstrap::resolve(profile, lookup_secret, env!("CARGO_PKG_VERSION"))
166 .await
167 .context("Failed to resolve the services bundle sources")?;
168
169 let after = cache.read_state();
170 let rows = diff_states(before, &after);
171 let outcome = outcome_for(&rows);
172
173 let reconciled = if outcome == RefreshOutcome::Changed {
174 reconcile_after_swap(profile, &root, cache, ctx).await?
175 } else {
176 Vec::new()
177 };
178 Ok((rows, outcome, reconciled))
179}
180
181#[must_use]
182pub fn diff_states(before: &ServicesBundleState, after: &ServicesBundleState) -> Vec<SourceRow> {
183 after
184 .sources
185 .iter()
186 .map(|(name, state)| {
187 let previous = before.sources.get(name);
188 SourceRow {
189 name: name.clone(),
190 previous_digest: previous.map_or_else(String::new, |s| s.digest.clone()),
191 changed: previous.is_none_or(|s| s.digest != state.digest),
192 new_digest: state.digest.clone(),
193 version: state.version.clone(),
194 }
195 })
196 .collect()
197}
198
199fn lookup_secret(name: &str) -> Option<String> {
200 SecretsBootstrap::get()
201 .ok()
202 .and_then(|secrets| secrets.get(name).cloned())
203}