offline-cli 0.3.3

The Offline Protocol developer CLI
//! Application setup an agent can do without the portal: the signed-in side
//! of [`offline_mcp::account`] (which holds the identities, policy, status
//! report and `offline.config.json` parsing shared with the MCP servers).
//! `offline apps …`, `offline apply` and the local MCP server's account tools
//! all run through here, so they behave identically.

pub mod mcp_tools;

pub use offline_mcp::account::{
    desired_from_config, detect, feature, merge_detected, native, normalize_origin, status_report,
    string_list, Desired, Feature, FEATURES,
};

use anyhow::{bail, Result};
use serde_json::{json, Value};

use crate::api::client::DeveloperClient;
use native::Native;

/// A signed-in developer working on one application.
pub struct AppContext {
    pub client: DeveloperClient,
    pub token: String,
    pub app_id: String,
}

impl AppContext {
    pub async fn application(&self) -> Result<Value> {
        self.client
            .get_application_json(&self.token, &self.app_id)
            .await
    }

    /// Read the native settings, apply `edit`, and save only if it changed.
    /// Returns `(changed, application)`.
    pub async fn edit_native<F>(&self, edit: F) -> Result<(bool, Value)>
    where
        F: FnOnce(&mut Native) -> Result<bool>,
    {
        let app = self.application().await?;
        let mut native = Native::from_application(&app);
        if !edit(&mut native)? {
            return Ok((false, app));
        }
        let updated = self
            .client
            .patch_application_json(&self.token, &self.app_id, &native.patch_body())
            .await?;
        Ok((true, updated))
    }

    /// Add and remove allowed web origins. Returns `(changed, origins)`.
    pub async fn edit_origins(
        &self,
        add: &[String],
        remove: &[String],
    ) -> Result<(bool, Vec<String>)> {
        let app = self.application().await?;
        let current = string_list(&app, "origins");
        let mut next = current.clone();
        for origin in add {
            let origin = normalize_origin(origin)?;
            if !next.contains(&origin) {
                next.push(origin);
            }
        }
        for origin in remove {
            let origin = normalize_origin(origin)?;
            next.retain(|o| o != &origin);
        }
        if next == current {
            return Ok((false, current));
        }
        let updated = self
            .client
            .patch_application_json(&self.token, &self.app_id, &json!({ "origins": next }))
            .await?;
        Ok((true, string_list(&updated, "origins")))
    }

    /// Every feature's current state, by CLI name.
    pub async fn features(&self) -> Result<Vec<(&'static Feature, bool)>> {
        let mut products: Vec<(&str, Value)> = Vec::new();
        let mut out = Vec::new();
        for f in FEATURES {
            if !products.iter().any(|(p, _)| *p == f.product) {
                let settings = self
                    .client
                    .get_product_settings_json(&self.token, &self.app_id, f.product)
                    .await?;
                products.push((f.product, settings));
            }
            let settings = &products.iter().find(|(p, _)| *p == f.product).unwrap().1;
            out.push((
                f,
                settings
                    .get(f.setting)
                    .and_then(Value::as_bool)
                    .unwrap_or(false),
            ));
        }
        Ok(out)
    }

    /// Turn features on or off. Returns the names that changed.
    pub async fn set_features(&self, wanted: &[(&'static Feature, bool)]) -> Result<Vec<String>> {
        let current = self.features().await?;
        let mut changed = Vec::new();
        for (f, on) in wanted {
            let now = current
                .iter()
                .find(|(c, _)| c.name == f.name)
                .map(|(_, v)| *v)
                .unwrap_or(false);
            if now == *on {
                continue;
            }
            self.client
                .patch_product_settings_json(
                    &self.token,
                    &self.app_id,
                    f.product,
                    &json!({ f.setting: on }),
                )
                .await?;
            changed.push(format!("{} {}", f.name, if *on { "on" } else { "off" }));
        }
        Ok(changed)
    }

    /// Development mode on for `hours` (1 to 72), or off (`None`). Returns
    /// the new `development_mode` (null when off).
    pub async fn set_development_mode(&self, hours: Option<u32>) -> Result<Value> {
        if let Some(h) = hours {
            if !(1..=72).contains(&h) {
                bail!("--hours must be between 1 and 72: development mode ends by itself");
            }
        }
        self.client
            .set_development_mode(&self.token, &self.app_id, hours)
            .await
    }

    pub async fn attestation_events(&self, limit: u32) -> Result<Value> {
        self.client
            .attestation_events(&self.token, &self.app_id, limit.clamp(1, 200))
            .await
    }

    /// What is set up, what is missing for sign-in on each platform, and the
    /// commands that fix it. `detected` (from the project on disk) lets the
    /// report name identities the project ships but the app has not registered.
    pub async fn status(&self, detected: Option<&detect::Detected>) -> Result<Value> {
        let app = self.application().await?;
        let features = self.features().await.ok();
        let keys = self
            .client
            .list_api_keys(&self.token, &self.app_id)
            .await
            .map(|k| k.len())
            .ok();
        Ok(status_report(&app, features.as_deref(), keys, detected))
    }
}

/// Converge the application to `desired`: add what is missing, never remove.
/// Returns the list of changes made (or that would be made with `dry_run`).
pub async fn apply(ctx: &AppContext, desired: &Desired, dry_run: bool) -> Result<Vec<String>> {
    if !desired.problems.is_empty() {
        bail!("offline.config.json: {}", desired.problems.join("; "));
    }
    let mut changes: Vec<String> = Vec::new();
    for (package, certs) in &desired.android {
        if certs.is_empty() {
            bail!(
                "app.native.android: {package} needs at least one signing certificate SHA-256 ({})",
                native::SHA256_HELP
            );
        }
    }
    let app = ctx.application().await?;
    let mut native = Native::from_application(&app);
    for (bundle, team) in &desired.ios {
        if native.add_ios(bundle, team) {
            changes.push(format!("register iOS {bundle} (Team ID {team})"));
        }
    }
    for (package, certs) in &desired.android {
        let before = native.android_apps();
        if native.add_android(package, certs) {
            let had: Vec<String> = before
                .iter()
                .find(|a| &a.package == package)
                .map(|a| a.sha256.clone())
                .unwrap_or_default();
            for c in certs.iter().filter(|c| !had.contains(c)) {
                changes.push(format!("register Android {package} certificate {c}"));
            }
        }
    }
    if let Some(on) = desired.allow_development {
        if native.set_allow_development(on) {
            changes.push(format!(
                "development builds {}",
                if on { "on" } else { "off" }
            ));
        }
    }
    let native_changed = native != Native::from_application(&app);
    let current_origins = string_list(&app, "origins");
    let new_origins: Vec<String> = desired
        .origins
        .iter()
        .filter(|o| !current_origins.contains(o))
        .cloned()
        .collect();
    for o in &new_origins {
        changes.push(format!("allow origin {o}"));
    }
    let current_features = ctx.features().await?;
    let feature_changes: Vec<(&'static Feature, bool)> = desired
        .features
        .iter()
        .filter(|(f, on)| {
            current_features
                .iter()
                .find(|(c, _)| c.name == f.name)
                .map(|(_, v)| v != on)
                .unwrap_or(true)
        })
        .cloned()
        .collect();
    for (f, on) in &feature_changes {
        changes.push(format!("{} {}", f.name, if *on { "on" } else { "off" }));
    }
    if dry_run {
        return Ok(changes);
    }
    if native_changed {
        ctx.client
            .patch_application_json(&ctx.token, &ctx.app_id, &native.patch_body())
            .await?;
    }
    if !new_origins.is_empty() {
        ctx.edit_origins(&new_origins, &[]).await?;
    }
    if !feature_changes.is_empty() {
        ctx.set_features(&feature_changes).await?;
    }
    Ok(changes)
}

#[cfg(test)]
mod http_tests;