Skip to main content

systemprompt_cli/commands/core/services/
refresh.rs

1//! `services refresh` — re-resolve the profile's bundle sources.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use 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_manifest::services::bundle::ServicesBundleState;
14use systemprompt_manifest::{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    // Why: a supervisor script distinguishes "nothing to do" from "restart me"
107    // by exit status, and every error path the binary has collapses to 1.
108    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}