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::services::bundle::ServicesBundleState;
14use systemprompt_models::{Profile, Secrets};
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 secrets = SecretsBootstrap::get()
57 .context("Secrets required to resolve the services bundle sources")?;
58 let cache = BundleCache::new(cache_root(profile));
59 let before = cache.read_state();
60
61 let (rows, outcome, reconciled) = if args.check {
62 let (rows, outcome) = check_sources(profile, secrets, &before).await?;
63 (rows, outcome, Vec::new())
64 } else {
65 Box::pin(swap_sources(profile, secrets, &cache, &before, ctx)).await?
66 };
67
68 let title = if args.check {
69 "Services Sources (check)"
70 } else {
71 "Services Sources"
72 };
73 render_result(
74 &CommandOutput::table_of(
75 vec![
76 "name",
77 "previous_digest",
78 "new_digest",
79 "version",
80 "changed",
81 ],
82 &rows,
83 )
84 .with_title(title),
85 &ctx.cli,
86 );
87
88 if !reconciled.is_empty() {
89 render_result(
90 &CommandOutput::table_of(
91 vec![
92 "bundle",
93 "inserted",
94 "updated",
95 "deleted",
96 "protected",
97 "inert_role_rules",
98 ],
99 &reconciled,
100 )
101 .with_title("Access Rules Reconciled"),
102 &ctx.cli,
103 );
104 }
105
106 let code = exit_code_for(outcome);
109 if code != 0 {
110 #[expect(
111 clippy::exit,
112 reason = "the documented exit code is this command's contract with a supervisor \
113 script; anyhow can only produce 1"
114 )]
115 std::process::exit(code);
116 }
117 Ok(())
118}
119
120async fn check_sources(
121 profile: &Profile,
122 secrets: &Secrets,
123 before: &ServicesBundleState,
124) -> Result<(Vec<SourceRow>, RefreshOutcome)> {
125 let client = reqwest::Client::builder()
126 .redirect(reqwest::redirect::Policy::none())
127 .build()
128 .context("Failed to build an HTTP client")?;
129
130 let mut rows = Vec::new();
131 for source in &profile.services.sources {
132 let auth = source
133 .auth_secret()
134 .and_then(|name| secrets.get(name).cloned());
135 let fetcher = AnyFetcher::from_source(source, auth, &client)
136 .with_context(|| format!("Source {} is not usable", source.name))?;
137 let remote = fetcher
138 .head()
139 .await
140 .with_context(|| format!("Source {} could not be reached", source.name))?;
141 let known = before.sources.get(&source.name);
142 let previous = known.map_or_else(String::new, |s| s.digest.clone());
143 rows.push(SourceRow {
144 name: source.name.clone(),
145 changed: remote.is_unknown() || previous != remote.digest,
146 new_digest: remote.digest,
147 previous_digest: previous,
148 version: known.map_or_else(String::new, |s| s.version.clone()),
149 });
150 }
151 let outcome = outcome_for(&rows);
152 Ok((rows, outcome))
153}
154
155#[must_use]
156pub fn outcome_for(rows: &[SourceRow]) -> RefreshOutcome {
157 if rows.iter().any(|row| row.changed) {
158 RefreshOutcome::Changed
159 } else {
160 RefreshOutcome::Unchanged
161 }
162}
163
164async fn swap_sources(
165 profile: &Profile,
166 secrets: &Secrets,
167 cache: &BundleCache,
168 before: &ServicesBundleState,
169 ctx: &CommandContext,
170) -> Result<(Vec<SourceRow>, RefreshOutcome, Vec<ReconcileRow>)> {
171 let root = ServicesSourceBootstrap::resolve(
172 profile,
173 |name| secrets.get(name).cloned(),
174 env!("CARGO_PKG_VERSION"),
175 )
176 .await
177 .context("Failed to resolve the services bundle sources")?;
178
179 let after = cache.read_state();
180 let rows = diff_states(before, &after);
181 let outcome = outcome_for(&rows);
182
183 let reconciled = if outcome == RefreshOutcome::Changed {
184 reconcile_after_swap(profile, &root, cache, ctx).await?
185 } else {
186 Vec::new()
187 };
188 Ok((rows, outcome, reconciled))
189}
190
191#[must_use]
192pub fn diff_states(before: &ServicesBundleState, after: &ServicesBundleState) -> Vec<SourceRow> {
193 after
194 .sources
195 .iter()
196 .map(|(name, state)| {
197 let previous = before.sources.get(name);
198 SourceRow {
199 name: name.clone(),
200 previous_digest: previous.map_or_else(String::new, |s| s.digest.clone()),
201 changed: previous.is_none_or(|s| s.digest != state.digest),
202 new_digest: state.digest.clone(),
203 version: state.version.clone(),
204 }
205 })
206 .collect()
207}