diff --git a/app/src-tauri/src/models/app_settings.rs b/app/src-tauri/src/models/app_settings.rs index 26758f9..18ffa3a 100644 --- a/app/src-tauri/src/models/app_settings.rs +++ b/app/src-tauri/src/models/app_settings.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use super::gateway_settings::GatewaySettings; +use super::marketplace::{Marketplace, MarketplaceAccount, MarketplaceInstall}; use super::project::{ClaudeCodeSettings, EnvVar}; fn default_true() -> bool { @@ -135,6 +136,16 @@ pub struct AppSettings { pub gateway: GatewaySettings, #[serde(default)] pub global_claude_code_settings: Option, + /// Sign-in accounts for private marketplace repos. Secrets live in the + /// OS keychain (`storage::secure::*_marketplace_token`), never here. + #[serde(default)] + pub marketplace_accounts: Vec, + /// Marketplace git repos the user added. + #[serde(default)] + pub marketplaces: Vec, + /// Items installed for every project (projects may opt out per item). + #[serde(default)] + pub global_marketplace_installs: Vec, /// Whether the terminal loads `@xterm/addon-webgl`. /// /// `None` is "auto", and auto is not the same answer on every platform. @@ -246,6 +257,9 @@ impl Default for AppSettings { stt: SttSettings::default(), gateway: GatewaySettings::default(), global_claude_code_settings: None, + marketplace_accounts: Vec::new(), + marketplaces: Vec::new(), + global_marketplace_installs: Vec::new(), terminal_gpu_rendering: None, } } diff --git a/app/src-tauri/src/models/marketplace.rs b/app/src-tauri/src/models/marketplace.rs new file mode 100644 index 0000000..d409983 --- /dev/null +++ b/app/src-tauri/src/models/marketplace.rs @@ -0,0 +1,399 @@ +//! Marketplace data model — see `docs/superpowers/specs/2026-09-27-marketplace-design.md`. +//! +//! Plain data plus the pure rules that decide what a project actually gets +//! ([`effective_installs`]) and what names are allowed to reach a container +//! path ([`is_valid_item_key`], [`marketplace_slug`]). + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ItemKind { + Agent, + Skill, + Command, + Hook, + Plugin, +} + +impl ItemKind { + /// The lowercase name used in report strings (`"agent:code-reviewer"`) and the manifest. + pub fn as_str(&self) -> &'static str { + match self { + ItemKind::Agent => "agent", + ItemKind::Skill => "skill", + ItemKind::Command => "command", + ItemKind::Hook => "hook", + ItemKind::Plugin => "plugin", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AccountMethod { + GhHost, + GhContainer, + Token, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MarketplaceAccount { + pub id: String, + pub label: String, + pub host: String, + pub method: AccountMethod, + #[serde(default)] + pub username: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Marketplace { + pub id: String, + pub name: String, + pub url: String, + #[serde(default)] + pub branch: Option, + #[serde(default)] + pub account_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct MarketplaceItemRef { + pub marketplace_id: String, + pub kind: ItemKind, + pub key: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MarketplaceInstall { + pub marketplace_id: String, + pub kind: ItemKind, + pub key: String, + pub commit: String, +} + +impl MarketplaceInstall { + pub fn item_ref(&self) -> MarketplaceItemRef { + MarketplaceItemRef { + marketplace_id: self.marketplace_id.clone(), + kind: self.kind, + key: self.key.clone(), + } + } +} + +/// What a project's container actually gets: the global installs minus the +/// ones this project opted out of, plus the project's own installs. When the +/// project installs an item that is also global, the project's entry (and so +/// its pin) wins. Sorted by item ref so the result is deterministic. +pub fn effective_installs( + global: &[MarketplaceInstall], + disabled: &[MarketplaceItemRef], + project: &[MarketplaceInstall], +) -> Vec { + let mut out: BTreeMap = BTreeMap::new(); + for install in global { + let item = install.item_ref(); + if disabled.contains(&item) { + continue; + } + out.insert(item, install.clone()); + } + for install in project { + out.insert(install.item_ref(), install.clone()); + } + out.into_values().collect() +} + +/// `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` — the only names that may become a +/// container path component. No `/`, no leading `.` or `-`, no shell +/// metacharacters. +pub fn is_valid_item_key(key: &str) -> bool { + let bytes = key.as_bytes(); + if bytes.is_empty() || bytes.len() > 64 { + return false; + } + if !bytes[0].is_ascii_alphanumeric() { + return false; + } + bytes + .iter() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) +} + +/// A container-safe, collision-free name for a marketplace: its name +/// lowercased to `[a-z0-9-]`, dashes collapsed, at most 32 characters, then +/// `-` and the first 8 characters of its id. An empty sanitised name becomes +/// `marketplace`. +pub fn marketplace_slug(name: &str, id: &str) -> String { + let mut base = String::new(); + for c in name.chars() { + let c = c.to_ascii_lowercase(); + if c.is_ascii_lowercase() || c.is_ascii_digit() { + base.push(c); + } else if !base.ends_with('-') && !base.is_empty() { + base.push('-'); + } + } + let mut base: String = base.trim_matches('-').chars().take(32).collect(); + while base.ends_with('-') { + base.pop(); + } + if base.is_empty() { + base.push_str("marketplace"); + } + let id_part: String = id + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .map(|c| c.to_ascii_lowercase()) + .take(8) + .collect(); + format!("{}-{}", base, id_part) +} + +/// A full, lowercase, 40-character hex object id. +pub fn is_valid_commit(commit: &str) -> bool { + commit.len() == 40 + && commit + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CatalogItem { + pub kind: ItemKind, + pub key: String, + pub name: String, + pub description: String, + /// Repo-relative path of the item (file or folder). + pub path: String, + /// `Some(reason)` when the item cannot be installed. + pub invalid: Option, + /// Hooks only: rendered commands with `${HOOK_DIR}` substituted. + #[serde(default)] + pub hook_commands: Vec, + /// Agents/commands/skills: the markdown body (≤ 64 KiB, truncated); + /// plugins: a component listing. + #[serde(default)] + pub preview: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct MarketplaceSnapshot { + pub marketplace_id: String, + pub head_commit: Option, + /// RFC 3339. + pub fetched_at: Option, + pub fetch_error: Option, + pub items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ItemUpdate { + pub item: MarketplaceItemRef, + pub pinned: String, + pub head: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FileChange { + Added, + Removed, + Modified, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FileDiff { + pub path: String, + pub change: FileChange, + /// Unified diff text; `None` when either side is binary. + pub unified: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct SkippedItem { + pub item: String, + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct SyncReport { + #[serde(default)] + pub installed: Vec, + #[serde(default)] + pub updated: Vec, + #[serde(default)] + pub removed: Vec, + #[serde(default)] + pub skipped: Vec, + #[serde(default)] + pub errors: Vec, + /// RFC 3339, set by the host. + #[serde(default)] + pub finished_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum InstallScope { + Global, + Project { project_id: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectSyncResult { + pub project_id: String, + pub report: SyncReport, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn install(market: &str, kind: ItemKind, key: &str, commit: &str) -> MarketplaceInstall { + MarketplaceInstall { + marketplace_id: market.to_string(), + kind, + key: key.to_string(), + commit: commit.to_string(), + } + } + + #[test] + fn effective_set_is_global_minus_disabled_plus_project() { + let global = vec![ + install("m1", ItemKind::Agent, "reviewer", "a"), + install("m1", ItemKind::Hook, "notify", "a"), + ]; + let disabled = vec![MarketplaceItemRef { + marketplace_id: "m1".into(), + kind: ItemKind::Hook, + key: "notify".into(), + }]; + let project = vec![install("m2", ItemKind::Skill, "tidy", "b")]; + + let got = effective_installs(&global, &disabled, &project); + + assert_eq!( + got, + vec![ + install("m1", ItemKind::Agent, "reviewer", "a"), + install("m2", ItemKind::Skill, "tidy", "b"), + ] + ); + } + + #[test] + fn project_pin_wins_over_global_pin() { + let global = vec![install("m1", ItemKind::Agent, "reviewer", "old")]; + let project = vec![install("m1", ItemKind::Agent, "reviewer", "new")]; + let got = effective_installs(&global, &[], &project); + assert_eq!(got, vec![install("m1", ItemKind::Agent, "reviewer", "new")]); + } + + #[test] + fn same_key_different_kind_are_different_items() { + let global = vec![ + install("m1", ItemKind::Agent, "x", "a"), + install("m1", ItemKind::Command, "x", "a"), + ]; + assert_eq!(effective_installs(&global, &[], &[]).len(), 2); + } + + #[test] + fn item_keys_follow_the_pattern() { + for ok in ["a", "code-reviewer", "A.b_c-9", &"x".repeat(64)] { + assert!(is_valid_item_key(ok), "{ok} should be valid"); + } + for bad in [ + "", + ".hidden", + "-flag", + "_x", + "a/b", + "a b", + "a;rm", + "$(x)", + "ä", + "..", + &"x".repeat(65), + ] { + assert!(!is_valid_item_key(bad), "{bad:?} should be invalid"); + } + } + + #[test] + fn slug_is_sanitised_and_suffixed_with_the_id() { + assert_eq!( + marketplace_slug("Triple-C Marketplace!", "1A2B3C4D-ffff"), + "triple-c-marketplace-1a2b3c4d" + ); + assert_eq!( + marketplace_slug("***", "abcdef0123"), + "marketplace-abcdef01" + ); + let long = marketplace_slug(&"x".repeat(80), "12345678"); + assert_eq!(long, format!("{}-12345678", "x".repeat(32))); + } + + #[test] + fn commits_must_be_full_lowercase_hex() { + assert!(is_valid_commit(&"a".repeat(40))); + assert!(!is_valid_commit(&"A".repeat(40))); + assert!(!is_valid_commit(&"a".repeat(39))); + assert!(!is_valid_commit("HEAD")); + } + + #[test] + fn install_scope_serialises_tagged() { + assert_eq!( + serde_json::to_value(InstallScope::Global).unwrap(), + serde_json::json!({"type": "global"}) + ); + assert_eq!( + serde_json::to_value(InstallScope::Project { + project_id: "p".into() + }) + .unwrap(), + serde_json::json!({"type": "project", "project_id": "p"}) + ); + } + + #[test] + fn kinds_serialise_snake_case() { + assert_eq!(serde_json::to_value(ItemKind::Plugin).unwrap(), "plugin"); + assert_eq!( + serde_json::to_value(AccountMethod::GhHost).unwrap(), + "gh_host" + ); + } + + #[test] + fn settings_and_projects_saved_before_the_marketplace_still_load() { + let mut settings = serde_json::to_value(crate::models::AppSettings::default()).unwrap(); + for key in [ + "marketplace_accounts", + "marketplaces", + "global_marketplace_installs", + ] { + settings.as_object_mut().unwrap().remove(key); + } + let settings: crate::models::AppSettings = serde_json::from_value(settings).unwrap(); + assert!(settings.marketplace_accounts.is_empty()); + assert!(settings.marketplaces.is_empty()); + assert!(settings.global_marketplace_installs.is_empty()); + + let mut project = + serde_json::to_value(crate::models::Project::new("p".to_string(), Vec::new())).unwrap(); + for key in ["marketplace_installs", "marketplace_disabled"] { + project.as_object_mut().unwrap().remove(key); + } + let project: crate::models::Project = serde_json::from_value(project).unwrap(); + assert!(project.marketplace_installs.is_empty()); + assert!(project.marketplace_disabled.is_empty()); + } +} diff --git a/app/src-tauri/src/models/mod.rs b/app/src-tauri/src/models/mod.rs index 10e2c0f..9935ee4 100644 --- a/app/src-tauri/src/models/mod.rs +++ b/app/src-tauri/src/models/mod.rs @@ -1,6 +1,7 @@ pub mod app_settings; pub mod container_config; pub mod gateway_settings; +pub mod marketplace; pub mod migration; pub mod note; pub mod project; @@ -10,6 +11,7 @@ pub mod update_info; pub use app_settings::*; pub use container_config::*; pub use gateway_settings::*; +pub use marketplace::*; pub use migration::*; pub use note::*; pub use project::*; diff --git a/app/src-tauri/src/models/project.rs b/app/src-tauri/src/models/project.rs index 0f32161..fb32c34 100644 --- a/app/src-tauri/src/models/project.rs +++ b/app/src-tauri/src/models/project.rs @@ -446,6 +446,12 @@ pub struct Project { /// User-defined display names for terminal tabs, keyed by session id. #[serde(default)] pub renamed_session_names: HashMap, + /// Marketplace items installed for this project only (spec §2). + #[serde(default)] + pub marketplace_installs: Vec, + /// Global marketplace installs this project opts out of. + #[serde(default)] + pub marketplace_disabled: Vec, pub created_at: String, pub updated_at: String, } @@ -693,6 +699,8 @@ impl Project { claude_instructions: None, claude_code_settings: None, renamed_session_names: HashMap::new(), + marketplace_installs: Vec::new(), + marketplace_disabled: Vec::new(), created_at: now.clone(), updated_at: now, } @@ -789,7 +797,10 @@ mod tests { } fn env(key: &str, value: &str) -> EnvVar { - EnvVar { key: key.to_string(), value: value.to_string() } + EnvVar { + key: key.to_string(), + value: value.to_string(), + } } #[test] @@ -887,7 +898,10 @@ mod tests { // `merge_claude_code_settings` spells it. `main` resolved this with // `if p.env_scrub { true } else { g.env_scrub }`, i.e. the global won — // and it has to go on winning, because the user never turned this off. - let global = ClaudeCodeSettings { env_scrub: Some(true), ..Default::default() }; + let global = ClaudeCodeSettings { + env_scrub: Some(true), + ..Default::default() + }; assert_eq!( stored.env_scrub.or(global.env_scrub), Some(true), @@ -902,7 +916,10 @@ mod tests { let json = r#"{ "env_scrub": false }"#; let chosen: ClaudeCodeSettings = serde_json::from_str(json).unwrap(); assert_eq!(chosen.env_scrub, Some(false)); - let global = ClaudeCodeSettings { env_scrub: Some(true), ..Default::default() }; + let global = ClaudeCodeSettings { + env_scrub: Some(true), + ..Default::default() + }; assert_eq!(chosen.env_scrub.or(global.env_scrub), Some(false)); } @@ -916,7 +933,10 @@ mod tests { assert_eq!(json, "{}"); assert!(!json.contains("null")); - let partial = ClaudeCodeSettings { env_scrub: Some(false), ..Default::default() }; + let partial = ClaudeCodeSettings { + env_scrub: Some(false), + ..Default::default() + }; let json = serde_json::to_string(&partial).unwrap(); assert_eq!(json, r#"{"env_scrub":false}"#); // And it reads back as what it is. @@ -984,7 +1004,10 @@ mod tests { }); let migrated = Project::migrate_from_value(legacy); let obj = migrated.as_object().unwrap(); - assert!(obj.contains_key("paths"), "the migration should still do its own job"); + assert!( + obj.contains_key("paths"), + "the migration should still do its own job" + ); assert!(!obj.contains_key("auth_bridge_enabled")); assert!(!obj.contains_key("browser_view_enabled")); } diff --git a/app/src/components/projects/PermissionModeControl.test.tsx b/app/src/components/projects/PermissionModeControl.test.tsx index 5249904..f1d6d83 100644 --- a/app/src/components/projects/PermissionModeControl.test.tsx +++ b/app/src/components/projects/PermissionModeControl.test.tsx @@ -32,6 +32,8 @@ const baseProject: Project = { claude_instructions: null, claude_code_settings: null, renamed_session_names: {}, + marketplace_installs: [], + marketplace_disabled: [], created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", }; diff --git a/app/src/components/projects/ProjectRow.test.tsx b/app/src/components/projects/ProjectRow.test.tsx index ffd6b6e..eae742a 100644 --- a/app/src/components/projects/ProjectRow.test.tsx +++ b/app/src/components/projects/ProjectRow.test.tsx @@ -60,6 +60,8 @@ const baseProject: Project = { claude_instructions: null, claude_code_settings: null, renamed_session_names: {}, + marketplace_installs: [], + marketplace_disabled: [], created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", }; diff --git a/app/src/components/projects/home/BrowserTab.test.tsx b/app/src/components/projects/home/BrowserTab.test.tsx index df7624b..649015c 100644 --- a/app/src/components/projects/home/BrowserTab.test.tsx +++ b/app/src/components/projects/home/BrowserTab.test.tsx @@ -125,6 +125,8 @@ const project: Project = { claude_instructions: null, claude_code_settings: null, renamed_session_names: {}, + marketplace_installs: [], + marketplace_disabled: [], created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", } as unknown as Project; diff --git a/app/src/components/projects/home/TaskEditorModal.test.tsx b/app/src/components/projects/home/TaskEditorModal.test.tsx index 62e942e..ad9c62d 100644 --- a/app/src/components/projects/home/TaskEditorModal.test.tsx +++ b/app/src/components/projects/home/TaskEditorModal.test.tsx @@ -44,6 +44,8 @@ const baseProject: Project = { claude_instructions: null, claude_code_settings: null, renamed_session_names: {}, + marketplace_installs: [], + marketplace_disabled: [], created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", }; diff --git a/app/src/components/projects/home/config/ModelSection.test.tsx b/app/src/components/projects/home/config/ModelSection.test.tsx index afbd6dd..f61c236 100644 --- a/app/src/components/projects/home/config/ModelSection.test.tsx +++ b/app/src/components/projects/home/config/ModelSection.test.tsx @@ -34,6 +34,8 @@ const baseProject: Project = { claude_instructions: null, claude_code_settings: null, renamed_session_names: {}, + marketplace_installs: [], + marketplace_disabled: [], created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", }; diff --git a/app/src/components/projects/home/config/RuntimeSection.test.tsx b/app/src/components/projects/home/config/RuntimeSection.test.tsx index 4733c09..e8e1ea7 100644 --- a/app/src/components/projects/home/config/RuntimeSection.test.tsx +++ b/app/src/components/projects/home/config/RuntimeSection.test.tsx @@ -47,6 +47,8 @@ const baseProject: Project = { claude_instructions: null, claude_code_settings: null, renamed_session_names: {}, + marketplace_installs: [], + marketplace_disabled: [], created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", }; diff --git a/app/src/components/projects/home/config/WorkspaceSection.test.tsx b/app/src/components/projects/home/config/WorkspaceSection.test.tsx index a44970b..2f39ad6 100644 --- a/app/src/components/projects/home/config/WorkspaceSection.test.tsx +++ b/app/src/components/projects/home/config/WorkspaceSection.test.tsx @@ -39,6 +39,8 @@ const baseProject: Project = { claude_instructions: null, claude_code_settings: null, renamed_session_names: {}, + marketplace_installs: [], + marketplace_disabled: [], created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", }; diff --git a/app/src/components/settings/SharedAuthSettings.test.tsx b/app/src/components/settings/SharedAuthSettings.test.tsx index dae02d7..6ab0a52 100644 --- a/app/src/components/settings/SharedAuthSettings.test.tsx +++ b/app/src/components/settings/SharedAuthSettings.test.tsx @@ -72,6 +72,8 @@ const baseProject: Project = { claude_instructions: null, claude_code_settings: null, renamed_session_names: {}, + marketplace_installs: [], + marketplace_disabled: [], created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", }; diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 849a5bf..2dcaa33 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -66,6 +66,8 @@ export interface Project { claude_instructions: string | null; claude_code_settings: ClaudeCodeSettings | null; renamed_session_names: Record; + marketplace_installs: MarketplaceInstall[]; + marketplace_disabled: MarketplaceItemRef[]; created_at: string; updated_at: string; } @@ -296,6 +298,81 @@ export interface AppSettings { * canvas renderer it would otherwise fall back to. See * `resolveTerminalGpuRendering` in `lib/terminalRenderer.ts`. */ terminal_gpu_rendering: boolean | null; + marketplace_accounts: MarketplaceAccount[]; + marketplaces: Marketplace[]; + global_marketplace_installs: MarketplaceInstall[]; +} + +// ── Marketplace (mirrors src-tauri/src/models/marketplace.rs) ─────────────── + +export type ItemKind = "agent" | "skill" | "command" | "hook" | "plugin"; +export type AccountMethod = "gh_host" | "gh_container" | "token"; +export interface MarketplaceAccount { + id: string; + label: string; + host: string; + method: AccountMethod; + username: string | null; +} +export interface Marketplace { + id: string; + name: string; + url: string; + branch: string | null; + account_id: string | null; +} +export interface MarketplaceItemRef { + marketplace_id: string; + kind: ItemKind; + key: string; +} +export interface MarketplaceInstall extends MarketplaceItemRef { + commit: string; +} +export interface CatalogItem { + kind: ItemKind; + key: string; + name: string; + description: string; + path: string; + invalid: string | null; + hook_commands: string[]; + preview: string; +} +export interface MarketplaceSnapshot { + marketplace_id: string; + head_commit: string | null; + fetched_at: string | null; + fetch_error: string | null; + items: CatalogItem[]; +} +export interface ItemUpdate { + item: MarketplaceItemRef; + pinned: string; + head: string; +} +export type FileChange = "added" | "removed" | "modified"; +export interface FileDiff { + path: string; + change: FileChange; + unified: string | null; +} +export interface SkippedItem { + item: string; + reason: string; +} +export interface SyncReport { + installed: string[]; + updated: string[]; + removed: string[]; + skipped: SkippedItem[]; + errors: string[]; + finished_at: string; +} +export type InstallScope = { type: "global" } | { type: "project"; project_id: string }; +export interface ProjectSyncResult { + project_id: string; + report: SyncReport; } /** What `preview_settings_import` returns before anything is applied —