From 2c09a130723d2904b5df5cc0356e667c124254b5 Mon Sep 17 00:00:00 2001 From: Miles Johnson Date: Thu, 9 Apr 2026 10:01:21 -0700 Subject: [PATCH 1/3] First pass. --- .gitignore | 4 + .moon/workspace.yml | 1 + Cargo.lock | 17 ++ backends/scoop/CHANGELOG.md | 5 + backends/scoop/Cargo.toml | 31 +++ backends/scoop/src/config.rs | 47 ++++ backends/scoop/src/lib.rs | 8 + backends/scoop/src/manifest.rs | 360 ++++++++++++++++++++++++++ backends/scoop/src/proto.rs | 258 ++++++++++++++++++ backends/scoop/tests/download_test.rs | 23 ++ backends/scoop/tests/metadata_test.rs | 49 ++++ backends/scoop/tests/versions_test.rs | 16 ++ 12 files changed, 819 insertions(+) create mode 100644 backends/scoop/CHANGELOG.md create mode 100644 backends/scoop/Cargo.toml create mode 100644 backends/scoop/src/config.rs create mode 100644 backends/scoop/src/lib.rs create mode 100644 backends/scoop/src/manifest.rs create mode 100644 backends/scoop/src/proto.rs create mode 100644 backends/scoop/tests/download_test.rs create mode 100644 backends/scoop/tests/metadata_test.rs create mode 100644 backends/scoop/tests/versions_test.rs diff --git a/.gitignore b/.gitignore index d9461fe6..b1205b74 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ target/ # moon .moon/cache .moon/docker + +# AI / agents +.claude/settings.local.json +.claude/worktrees diff --git a/.moon/workspace.yml b/.moon/workspace.yml index cf05b400..f9794f4b 100644 --- a/.moon/workspace.yml +++ b/.moon/workspace.yml @@ -4,6 +4,7 @@ projects: root: . # Backends asdf-backend: backends/asdf + scoop-backend: backends/scoop # Extensions download-extension: extensions/download migrate-nx-extension: extensions/migrate-nx diff --git a/Cargo.lock b/Cargo.lock index b29aae9e..bb5f2959 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4674,6 +4674,23 @@ dependencies = [ "url", ] +[[package]] +name = "scoop_backend" +version = "0.1.0" +dependencies = [ + "backend_common", + "extism-pdk", + "proto_pdk", + "proto_pdk_test_utils", + "rustc-hash", + "schematic", + "serde", + "serde_json", + "starbase_sandbox", + "starbase_utils", + "tokio", +] + [[package]] name = "scopeguard" version = "1.2.0" diff --git a/backends/scoop/CHANGELOG.md b/backends/scoop/CHANGELOG.md new file mode 100644 index 00000000..695b788f --- /dev/null +++ b/backends/scoop/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## Unreleased + +- Initial release. diff --git a/backends/scoop/Cargo.toml b/backends/scoop/Cargo.toml new file mode 100644 index 00000000..3dabca55 --- /dev/null +++ b/backends/scoop/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "scoop_backend" +version = "0.1.0" +edition = "2024" + +[package.metadata.release] +pre-release-replacements = [ + { file = "./CHANGELOG.md", search = "Unreleased", replace = "{{version}}" }, +] + +[lib] +crate-type = ["cdylib"] + +[dependencies] +backend_common = { path = "../../crates/backend-common" } +extism-pdk = { workspace = true } +proto_pdk = { workspace = true } +rustc-hash = { workspace = true } +schematic = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +starbase_utils = { workspace = true } + +[dev-dependencies] +proto_pdk_test_utils = { workspace = true } +starbase_sandbox = { workspace = true } +tokio = { workspace = true } + +[features] +default = ["wasm"] +wasm = [] diff --git a/backends/scoop/src/config.rs b/backends/scoop/src/config.rs new file mode 100644 index 00000000..9650baf2 --- /dev/null +++ b/backends/scoop/src/config.rs @@ -0,0 +1,47 @@ +use proto_pdk::*; + +const DEFAULT_BUCKET: &str = "ScoopInstaller/Main"; +const DEFAULT_BRANCH: &str = "master"; + +/// Configuration for the Scoop backend plugin. +/// https://github.com/ScoopInstaller/Scoop/wiki/App-Manifests +#[derive(Debug, Default, schematic::Schematic, serde::Deserialize, serde::Serialize)] +#[serde(default, deny_unknown_fields, rename_all = "kebab-case")] +pub struct ScoopToolConfig { + /// The GitHub repository for the scoop bucket. Defaults to "ScoopInstaller/Main". + pub bucket: Option, + + /// The branch of the bucket repository. Defaults to "master". + pub bucket_branch: Option, + + /// Override the manifest filename (without .json extension). + /// Defaults to the tool ID. + pub manifest_name: Option, +} + +impl ScoopToolConfig { + pub fn get_bucket(&self) -> &str { + self.bucket.as_deref().unwrap_or(DEFAULT_BUCKET) + } + + pub fn get_branch(&self) -> &str { + self.bucket_branch.as_deref().unwrap_or(DEFAULT_BRANCH) + } + + pub fn get_manifest_name(&self) -> AnyResult { + match &self.manifest_name { + Some(name) => Ok(name.clone()), + None => Ok(get_plugin_id()?.to_string()), + } + } + + pub fn get_manifest_url(&self) -> AnyResult { + let bucket = self.get_bucket(); + let branch = self.get_branch(); + let name = self.get_manifest_name()?; + + Ok(format!( + "https://raw.githubusercontent.com/{bucket}/refs/heads/{branch}/bucket/{name}.json" + )) + } +} diff --git a/backends/scoop/src/lib.rs b/backends/scoop/src/lib.rs new file mode 100644 index 00000000..2c15a7b2 --- /dev/null +++ b/backends/scoop/src/lib.rs @@ -0,0 +1,8 @@ +pub mod config; +pub mod manifest; + +#[cfg(feature = "wasm")] +mod proto; + +#[cfg(feature = "wasm")] +pub use proto::*; diff --git a/backends/scoop/src/manifest.rs b/backends/scoop/src/manifest.rs new file mode 100644 index 00000000..b2eea98a --- /dev/null +++ b/backends/scoop/src/manifest.rs @@ -0,0 +1,360 @@ +use serde::Deserialize; +use serde::de::{self, Deserializer, SeqAccess, Visitor}; +use std::collections::HashMap; +use std::fmt; + +/// A value that can be either a single string or an array of strings. +#[derive(Debug, Clone, Default)] +pub struct StringOrArray(pub Vec); + +impl StringOrArray { + pub fn first(&self) -> Option<&str> { + self.0.first().map(|s| s.as_str()) + } +} + +impl<'de> Deserialize<'de> for StringOrArray { + fn deserialize>(deserializer: D) -> Result { + struct StringOrArrayVisitor; + + impl<'de> Visitor<'de> for StringOrArrayVisitor { + type Value = StringOrArray; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a string or array of strings") + } + + fn visit_str(self, v: &str) -> Result { + Ok(StringOrArray(vec![v.to_owned()])) + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut values = Vec::new(); + while let Some(v) = seq.next_element::()? { + values.push(v); + } + Ok(StringOrArray(values)) + } + } + + deserializer.deserialize_any(StringOrArrayVisitor) + } +} + +/// An executable entry from the `bin` field. +#[derive(Debug, Clone)] +pub struct BinEntry { + pub exe_path: String, + pub alias: Option, +} + +/// The `bin` field can be a string, array of strings, or array of [exe, alias, ...] arrays. +#[derive(Debug, Clone, Default)] +pub struct ScoopBin(pub Vec); + +impl<'de> Deserialize<'de> for ScoopBin { + fn deserialize>(deserializer: D) -> Result { + struct ScoopBinVisitor; + + impl<'de> Visitor<'de> for ScoopBinVisitor { + type Value = ScoopBin; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a string, array of strings, or array of [exe, alias] arrays") + } + + fn visit_str(self, v: &str) -> Result { + Ok(ScoopBin(vec![BinEntry { + exe_path: v.to_owned(), + alias: None, + }])) + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut entries = Vec::new(); + + while let Some(value) = seq.next_element::()? { + match value { + serde_json::Value::String(s) => { + entries.push(BinEntry { + exe_path: s, + alias: None, + }); + } + serde_json::Value::Array(arr) => { + let exe_path = arr + .first() + .and_then(|v| v.as_str()) + .ok_or_else(|| { + de::Error::custom("bin array entry must have an exe path") + })? + .to_owned(); + let alias = arr.get(1).and_then(|v| v.as_str()).map(|s| s.to_owned()); + entries.push(BinEntry { exe_path, alias }); + } + _ => { + return Err(de::Error::custom("unexpected bin entry type")); + } + } + } + + Ok(ScoopBin(entries)) + } + } + + deserializer.deserialize_any(ScoopBinVisitor) + } +} + +/// Per-architecture download configuration. +#[derive(Debug, Clone, Deserialize)] +pub struct ScoopArchEntry { + pub url: Option, + pub hash: Option, + pub extract_dir: Option, + pub bin: Option, + pub env_add_path: Option, +} + +/// Architecture-specific overrides. +#[derive(Debug, Clone, Deserialize)] +pub struct ScoopArchitecture { + #[serde(rename = "32bit")] + pub x32: Option, + #[serde(rename = "64bit")] + pub x64: Option, + pub arm64: Option, +} + +impl ScoopArchitecture { + pub fn get_entry(&self, arch: &str) -> Option<&ScoopArchEntry> { + match arch { + "32bit" => self.x32.as_ref(), + "64bit" => self.x64.as_ref(), + "arm64" => self.arm64.as_ref(), + _ => None, + } + } +} + +/// Autoupdate hash configuration. +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum ScoopAutoupdateHash { + Url(String), + Config { + url: Option, + regex: Option, + jsonpath: Option, + }, +} + +/// Autoupdate architecture entry. +#[derive(Debug, Clone, Deserialize)] +pub struct ScoopAutoupdateArchEntry { + pub url: Option, + pub hash: Option, + pub extract_dir: Option, +} + +/// Autoupdate architecture. +#[derive(Debug, Clone, Deserialize)] +pub struct ScoopAutoupdateArchitecture { + #[serde(rename = "32bit")] + pub x32: Option, + #[serde(rename = "64bit")] + pub x64: Option, + pub arm64: Option, +} + +impl ScoopAutoupdateArchitecture { + pub fn get_entry(&self, arch: &str) -> Option<&ScoopAutoupdateArchEntry> { + match arch { + "32bit" => self.x32.as_ref(), + "64bit" => self.x64.as_ref(), + "arm64" => self.arm64.as_ref(), + _ => None, + } + } +} + +/// Autoupdate configuration. +#[derive(Debug, Clone, Deserialize)] +pub struct ScoopAutoupdate { + pub url: Option, + pub hash: Option, + pub extract_dir: Option, + pub architecture: Option, +} + +/// A Scoop app manifest. +/// https://github.com/ScoopInstaller/Scoop/wiki/App-Manifests +#[derive(Debug, Clone, Deserialize)] +pub struct ScoopManifest { + pub version: String, + pub description: Option, + pub homepage: Option, + pub license: Option, + pub url: Option, + pub hash: Option, + pub extract_dir: Option, + pub architecture: Option, + pub bin: Option, + pub env_add_path: Option, + pub env_set: Option>, + pub persist: Option, + pub autoupdate: Option, +} + +impl ScoopManifest { + /// Get the download URL for a given architecture. + /// Falls back to the top-level `url` if no architecture-specific URL exists. + pub fn get_url_for_arch(&self, arch: &str) -> Option { + if let Some(architecture) = &self.architecture + && let Some(entry) = architecture.get_entry(arch) + && let Some(url) = &entry.url + { + return url.first().map(|s| s.to_owned()); + } + self.url + .as_ref() + .and_then(|u| u.first()) + .map(|s| s.to_owned()) + } + + /// Get the SHA256 hash for a given architecture. + pub fn get_hash_for_arch(&self, arch: &str) -> Option { + if let Some(architecture) = &self.architecture + && let Some(entry) = architecture.get_entry(arch) + && let Some(hash) = &entry.hash + { + return hash.first().map(|s| s.to_owned()); + } + self.hash + .as_ref() + .and_then(|h| h.first()) + .map(|s| s.to_owned()) + } + + /// Get the extract directory for a given architecture. + pub fn get_extract_dir_for_arch(&self, arch: &str) -> Option { + if let Some(architecture) = &self.architecture + && let Some(entry) = architecture.get_entry(arch) + && let Some(dir) = &entry.extract_dir + { + return dir.first().map(|s| s.to_owned()); + } + self.extract_dir + .as_ref() + .and_then(|d| d.first()) + .map(|s| s.to_owned()) + } + + /// Get executable entries for a given architecture. + /// Falls back to the top-level `bin` if no architecture-specific bin exists. + pub fn get_executables_for_arch(&self, arch: &str) -> Vec { + if let Some(architecture) = &self.architecture + && let Some(entry) = architecture.get_entry(arch) + && let Some(bin) = &entry.bin + { + return bin.0.clone(); + } + self.bin.as_ref().map(|b| b.0.clone()).unwrap_or_default() + } + + /// Get env_add_path entries for a given architecture. + pub fn get_env_add_paths_for_arch(&self, arch: &str) -> Vec { + if let Some(architecture) = &self.architecture + && let Some(entry) = architecture.get_entry(arch) + && let Some(paths) = &entry.env_add_path + { + return paths.0.clone(); + } + self.env_add_path + .as_ref() + .map(|p| p.0.clone()) + .unwrap_or_default() + } + + /// Substitute version placeholders in a URL template. + pub fn substitute_version(template: &str, version: &str) -> String { + let mut result = template.replace("$version", version); + + let parts: Vec<&str> = version.splitn(4, '.').collect(); + if let Some(major) = parts.first() { + result = result.replace("$majorVersion", major); + } + if let Some(minor) = parts.get(1) { + result = result.replace("$minorVersion", minor); + } + if let Some(patch) = parts.get(2) { + result = result.replace("$patchVersion", patch); + } + if let Some(build) = parts.get(3) { + result = result.replace("$buildVersion", build); + } + + result + } + + /// Substitute version placeholders, also resolving `$baseurl` from the manifest's + /// current download URL. + pub fn substitute_version_with_baseurl( + &self, + template: &str, + version: &str, + arch: &str, + ) -> String { + let mut result = Self::substitute_version(template, version); + + if result.contains("$baseurl") + && let Some(url) = self.get_url_for_arch(arch) + && let Some(pos) = url.rfind('/') + { + let base = Self::substitute_version(&url[..pos], version); + result = result.replace("$baseurl", &base); + } + + result + } + + /// Resolve the autoupdate URL for a given version and architecture. + pub fn resolve_autoupdate_url(&self, version: &str, arch: &str) -> Option { + let autoupdate = self.autoupdate.as_ref()?; + + // Try architecture-specific autoupdate first + if let Some(arch_autoupdate) = &autoupdate.architecture + && let Some(entry) = arch_autoupdate.get_entry(arch) + && let Some(url) = &entry.url + { + return url + .first() + .map(|t| self.substitute_version_with_baseurl(t, version, arch)); + } + + // Fall back to top-level autoupdate URL + autoupdate + .url + .as_ref() + .and_then(|u| u.first()) + .map(|t| self.substitute_version_with_baseurl(t, version, arch)) + } + + /// Resolve the autoupdate extract_dir for a given version and architecture. + pub fn resolve_autoupdate_extract_dir(&self, version: &str, arch: &str) -> Option { + let autoupdate = self.autoupdate.as_ref()?; + + if let Some(arch_autoupdate) = &autoupdate.architecture + && let Some(entry) = arch_autoupdate.get_entry(arch) + && let Some(dir) = &entry.extract_dir + { + return dir.first().map(|t| Self::substitute_version(t, version)); + } + + autoupdate + .extract_dir + .as_ref() + .and_then(|d| d.first()) + .map(|t| Self::substitute_version(t, version)) + } +} diff --git a/backends/scoop/src/proto.rs b/backends/scoop/src/proto.rs new file mode 100644 index 00000000..ae1dc576 --- /dev/null +++ b/backends/scoop/src/proto.rs @@ -0,0 +1,258 @@ +use crate::config::ScoopToolConfig; +use crate::manifest::ScoopManifest; +use backend_common::enable_tracing; +use extism_pdk::*; +use proto_pdk::*; +use rustc_hash::FxHashMap; +use schematic::SchemaBuilder; + +fn is_scoop() -> bool { + get_plugin_id().is_ok_and(|id| id == "scoop") +} + +fn map_arch(arch: HostArch) -> &'static str { + match arch { + HostArch::X86 => "32bit", + HostArch::X64 => "64bit", + HostArch::Arm64 => "arm64", + _ => "64bit", + } +} + +fn fetch_manifest(config: &ScoopToolConfig) -> AnyResult { + let url = config.get_manifest_url()?; + let manifest: ScoopManifest = fetch_json(&url)?; + + Ok(manifest) +} + +#[plugin_fn] +pub fn register_tool(Json(input): Json) -> FnResult> { + enable_tracing(); + + Ok(Json(RegisterToolOutput { + name: if input.id == "scoop" { + input.id.to_string() + } else { + format!("scoop:{}", input.id) + }, + type_of: if input.id == "scoop" { + PluginType::VersionManager + } else { + PluginType::Language + }, + minimum_proto_version: Some(Version::new(0, 56, 0)), + plugin_version: Version::parse(env!("CARGO_PKG_VERSION")).ok(), + unstable: Switch::Toggle(true), + ..RegisterToolOutput::default() + })) +} + +#[plugin_fn] +pub fn define_tool_config(_: ()) -> FnResult> { + Ok(Json(DefineToolConfigOutput { + schema: SchemaBuilder::build_root::(), + })) +} + +#[plugin_fn] +pub fn register_backend( + Json(input): Json, +) -> FnResult> { + let env = get_host_environment()?; + + if !env.os.is_windows() { + return Err(PluginError::UnsupportedOS { + tool: input.id.to_string(), + os: env.os.to_string(), + } + .into()); + } + + let config = get_tool_config::()?; + + Ok(Json(RegisterBackendOutput { + backend_id: Id::new(config.get_manifest_name()?)?, + exes: vec![], + source: None, + })) +} + +#[plugin_fn] +pub fn load_versions(Json(_): Json) -> FnResult> { + let mut output = LoadVersionsOutput::default(); + + if is_scoop() { + return Ok(Json(output)); + } + + let config = get_tool_config::()?; + let manifest = fetch_manifest(&config)?; + + let version = VersionSpec::parse(&manifest.version)?; + let unresolved = UnresolvedVersionSpec::parse(&manifest.version)?; + + output.versions.push(version); + output.latest = Some(unresolved.clone()); + output.aliases.insert("latest".into(), unresolved.clone()); + output.aliases.insert("stable".into(), unresolved); + + Ok(Json(output)) +} + +#[plugin_fn] +pub fn resolve_version( + Json(input): Json, +) -> FnResult> { + let mut output = ResolveVersionOutput::default(); + + if let UnresolvedVersionSpec::Alias(alias) = &input.initial + && (alias == "latest" || alias == "stable") + { + let config = get_tool_config::()?; + let manifest = fetch_manifest(&config)?; + + output.candidate = Some(UnresolvedVersionSpec::parse(&manifest.version)?); + } + + Ok(Json(output)) +} + +#[plugin_fn] +pub fn download_prebuilt( + Json(input): Json, +) -> FnResult> { + let env = get_host_environment()?; + + check_supported_os_and_arch( + "Scoop", + &env, + permutations![ + HostOS::Windows => [HostArch::X64, HostArch::X86, HostArch::Arm64], + ], + )?; + + let config = get_tool_config::()?; + let manifest = fetch_manifest(&config)?; + let arch = map_arch(env.arch); + let version = input.context.version.to_string(); + let is_current = version == manifest.version; + + let (download_url, archive_prefix, checksum) = if is_current { + // Use direct URLs from the manifest for the current version + let url = manifest.get_url_for_arch(arch).ok_or_else(|| { + PluginError::Message(format!( + "No download URL found for architecture {arch} in the Scoop manifest" + )) + })?; + let extract_dir = manifest.get_extract_dir_for_arch(arch); + let hash = manifest.get_hash_for_arch(arch); + + (url, extract_dir, hash) + } else { + // Use autoupdate URL templates for other versions + let url = manifest + .resolve_autoupdate_url(&version, arch) + .ok_or_else(|| { + PluginError::Message(format!( + "No autoupdate URL template found for architecture {arch} in the Scoop manifest. \ + Only the current version ({}) can be downloaded without autoupdate configuration.", + manifest.version + )) + })?; + let extract_dir = manifest.resolve_autoupdate_extract_dir(&version, arch); + + // Checksums are not available for non-current versions via simple autoupdate + (url, extract_dir, None) + }; + + Ok(Json(DownloadPrebuiltOutput { + download_url, + archive_prefix, + checksum: checksum.map(Checksum::sha256), + ..DownloadPrebuiltOutput::default() + })) +} + +#[plugin_fn] +pub fn locate_executables( + Json(_): Json, +) -> FnResult> { + let mut output = LocateExecutablesOutput::default(); + + if is_scoop() { + return Ok(Json(output)); + } + + let env = get_host_environment()?; + let arch = map_arch(env.arch); + let config = get_tool_config::()?; + let manifest = fetch_manifest(&config)?; + + let id = get_plugin_id()?; + let executables = manifest.get_executables_for_arch(arch); + + for entry in &executables { + let name = entry.alias.as_deref().unwrap_or_else(|| { + // Extract filename without extension as the name + entry + .exe_path + .rsplit(['/', '\\']) + .next() + .unwrap_or(&entry.exe_path) + }); + + let name_without_ext = name + .strip_suffix(".exe") + .or_else(|| name.strip_suffix(".cmd")) + .or_else(|| name.strip_suffix(".bat")) + .unwrap_or(name); + + output.exes.insert( + name_without_ext.to_owned(), + ExecutableConfig { + primary: id.as_str() == name_without_ext, + exe_path: Some(entry.exe_path.clone().into()), + ..Default::default() + }, + ); + } + + // Add env_add_path directories + let paths = manifest.get_env_add_paths_for_arch(arch); + for path in paths { + if path == "." { + output.exes_dirs.push(".".into()); + } else { + output.exes_dirs.push(path.into()); + } + } + + // If no executables found, add a default based on the tool ID + if output.exes.is_empty() { + output.exes.insert( + id.to_string(), + ExecutableConfig::new_primary(format!("{id}.exe")), + ); + } + + Ok(Json(output)) +} + +#[plugin_fn] +pub fn pre_run(Json(_): Json) -> FnResult> { + let mut output = RunHookResult::default(); + + let config = get_tool_config::()?; + let manifest = fetch_manifest(&config)?; + + if let Some(env_set) = manifest.env_set { + let mut env = FxHashMap::default(); + for (key, value) in env_set { + env.insert(key, value); + } + output.env = Some(env); + } + + Ok(Json(output)) +} diff --git a/backends/scoop/tests/download_test.rs b/backends/scoop/tests/download_test.rs new file mode 100644 index 00000000..c0ab72ed --- /dev/null +++ b/backends/scoop/tests/download_test.rs @@ -0,0 +1,23 @@ +#[cfg(windows)] +mod scoop_backend { + use proto_pdk_test_utils::*; + + #[tokio::test(flavor = "multi_thread")] + async fn downloads_prebuilt() { + let sandbox = create_empty_proto_sandbox(); + let plugin = sandbox.create_plugin("scoop:jq").await; + + let output = plugin + .download_prebuilt(DownloadPrebuiltInput { + context: ToolContext { + version: VersionSpec::parse("1.7.1").unwrap(), + ..Default::default() + }, + ..Default::default() + }) + .await; + + assert!(!output.download_url.is_empty()); + assert!(output.download_url.contains("1.7.1")); + } +} diff --git a/backends/scoop/tests/metadata_test.rs b/backends/scoop/tests/metadata_test.rs new file mode 100644 index 00000000..7a6a1865 --- /dev/null +++ b/backends/scoop/tests/metadata_test.rs @@ -0,0 +1,49 @@ +mod scoop_backend { + use proto_pdk_test_utils::*; + + #[tokio::test(flavor = "multi_thread")] + async fn registers_metadata() { + let sandbox = create_empty_proto_sandbox(); + let plugin = sandbox.create_plugin("jq").await; + + let metadata = plugin + .register_tool(RegisterToolInput { id: Id::raw("jq") }) + .await; + + assert_eq!(metadata.name, "scoop:jq"); + assert_eq!( + metadata.plugin_version.unwrap().to_string(), + env!("CARGO_PKG_VERSION") + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn registers_metadata_as_scoop() { + let sandbox = create_empty_proto_sandbox(); + let plugin = sandbox.create_plugin("scoop").await; + + let metadata = plugin + .register_tool(RegisterToolInput { + id: Id::raw("scoop"), + }) + .await; + + assert_eq!(metadata.name, "scoop"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn registers_backend() { + let sandbox = create_empty_proto_sandbox(); + let plugin = sandbox.create_plugin("jq").await; + + let metadata = plugin + .register_backend(RegisterBackendInput { + id: Id::raw("jq"), + ..Default::default() + }) + .await; + + assert_eq!(metadata.backend_id, "jq"); + assert!(metadata.source.is_none()); + } +} diff --git a/backends/scoop/tests/versions_test.rs b/backends/scoop/tests/versions_test.rs new file mode 100644 index 00000000..1a4b9b11 --- /dev/null +++ b/backends/scoop/tests/versions_test.rs @@ -0,0 +1,16 @@ +mod scoop_backend { + use proto_pdk_test_utils::*; + + #[tokio::test(flavor = "multi_thread")] + async fn loads_versions() { + let sandbox = create_empty_proto_sandbox(); + let plugin = sandbox.create_plugin("scoop:jq").await; + + let output = plugin.load_versions(LoadVersionsInput::default()).await; + + assert!(!output.versions.is_empty()); + assert!(output.latest.is_some()); + assert!(output.aliases.contains_key("latest")); + assert!(output.aliases.contains_key("stable")); + } +} From eddc4f9e06a3525bdcc9418c1506adf2aadb687a Mon Sep 17 00:00:00 2001 From: Miles Johnson Date: Thu, 9 Apr 2026 14:56:54 -0700 Subject: [PATCH 2/3] Update manifest. --- backends/scoop/src/manifest.rs | 536 +++++++++++++++++++++++---------- backends/scoop/src/proto.rs | 24 +- 2 files changed, 382 insertions(+), 178 deletions(-) diff --git a/backends/scoop/src/manifest.rs b/backends/scoop/src/manifest.rs index b2eea98a..adced97e 100644 --- a/backends/scoop/src/manifest.rs +++ b/backends/scoop/src/manifest.rs @@ -1,212 +1,389 @@ +//! Serde types for the Scoop app manifest JSON format. +//! +//! Derived from the JSON schema at: +//! https://github.com/ScoopInstaller/Scoop/blob/master/schema.json + use serde::Deserialize; -use serde::de::{self, Deserializer, SeqAccess, Visitor}; use std::collections::HashMap; -use std::fmt; -/// A value that can be either a single string or an array of strings. -#[derive(Debug, Clone, Default)] -pub struct StringOrArray(pub Vec); +// --------------------------------------------------------------------------- +// Primitive union types (from schema definitions) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum OneOrMany { + One(T1), + Many(Vec), +} -impl StringOrArray { - pub fn first(&self) -> Option<&str> { - self.0.first().map(|s| s.as_str()) +impl OneOrMany { + pub fn first(&self) -> Option<&T1> { + match self { + Self::One(inner) => Some(inner), + Self::Many(_) => None, + } } } -impl<'de> Deserialize<'de> for StringOrArray { - fn deserialize>(deserializer: D) -> Result { - struct StringOrArrayVisitor; +impl Default for OneOrMany { + fn default() -> Self { + Self::Many(vec![]) + } +} - impl<'de> Visitor<'de> for StringOrArrayVisitor { - type Value = StringOrArray; +/// `stringOrArrayOfStrings`: a single string or an array of strings. +/// +/// Schema: `anyOf: [string, array]` +pub type StringOrArray = OneOrMany; + +/// `stringOrArrayOfStringsOrAnArrayOfArrayOfStrings`: +/// a string, or an array whose elements are each a `stringOrArrayOfStrings`. +/// +/// Schema: `anyOf: [string, array]` +/// +/// Used for `bin` and `persist`. Each array element may be: +/// - A plain string (e.g. `"node.exe"`) +/// - A sub-array of strings (e.g. `["node.exe", "node"]`) +/// +/// We normalise every element into a `Vec` so callers get a uniform +/// `Vec>`. +pub type StringOrArrayNested = OneOrMany>; + +/// `hash`: a hash-pattern string or an array of hash-pattern strings. +/// +/// Schema: `anyOf: [hashPattern, array]` +/// +/// Hash patterns are either bare hex (SHA-256 by default) or +/// `"sha1:"`, `"sha256:"`, `"sha512:"`, `"md5:"`. +pub type Hash = StringOrArray; + +/// `shortcutsArray`: an array of shortcut entries, each being `[target, name, params?, icon?]`. +pub type ShortcutsArray = Vec>; + +// --------------------------------------------------------------------------- +// Hash extraction (used in autoupdate) +// --------------------------------------------------------------------------- + +/// `hashExtraction`: describes how to extract a hash from a URL. +/// +/// Schema: object with optional `find`, `regex`, `jp`, `jsonpath`, `xpath`, +/// `mode`, `type`, `url`. +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(default)] +pub struct HashExtraction { + #[serde(alias = "find")] + pub regex: Option, + #[serde(alias = "jp")] + pub jsonpath: Option, + pub xpath: Option, + pub mode: Option, + #[serde(rename = "type")] + pub hash_type: Option, + pub url: Option, +} - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("a string or array of strings") - } +/// Extraction modes for `hashExtraction.mode`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum HashExtractionMode { + Download, + Extract, + Json, + Xpath, + Rdf, + Metalink, + Fosshub, + SourceForge, +} - fn visit_str(self, v: &str) -> Result { - Ok(StringOrArray(vec![v.to_owned()])) - } +/// Hash algorithm types (deprecated in schema). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum HashType { + Md5, + Sha1, + Sha256, + Sha512, +} - fn visit_seq>(self, mut seq: A) -> Result { - let mut values = Vec::new(); - while let Some(v) = seq.next_element::()? { - values.push(v); - } - Ok(StringOrArray(values)) - } - } +// --------------------------------------------------------------------------- +// License +// --------------------------------------------------------------------------- + +/// `license`: either an SPDX identifier string or `{ identifier, url? }`. +/// +/// Schema: `anyOf: [licenseIdentifiers, { identifier: string, url?: string }]` +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum License { + Identifier(String), + Full { + identifier: String, + url: Option, + }, +} - deserializer.deserialize_any(StringOrArrayVisitor) +impl Default for License { + fn default() -> Self { + Self::Identifier("mit".into()) } } -/// An executable entry from the `bin` field. -#[derive(Debug, Clone)] -pub struct BinEntry { - pub exe_path: String, - pub alias: Option, +// --------------------------------------------------------------------------- +// Installer / Uninstaller +// --------------------------------------------------------------------------- + +/// `installer`: installation configuration. +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(default)] +pub struct Installer { + pub args: Option, + pub file: Option, + pub script: Option, + pub keep: Option, } -/// The `bin` field can be a string, array of strings, or array of [exe, alias, ...] arrays. -#[derive(Debug, Clone, Default)] -pub struct ScoopBin(pub Vec); - -impl<'de> Deserialize<'de> for ScoopBin { - fn deserialize>(deserializer: D) -> Result { - struct ScoopBinVisitor; - - impl<'de> Visitor<'de> for ScoopBinVisitor { - type Value = ScoopBin; - - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("a string, array of strings, or array of [exe, alias] arrays") - } - - fn visit_str(self, v: &str) -> Result { - Ok(ScoopBin(vec![BinEntry { - exe_path: v.to_owned(), - alias: None, - }])) - } - - fn visit_seq>(self, mut seq: A) -> Result { - let mut entries = Vec::new(); - - while let Some(value) = seq.next_element::()? { - match value { - serde_json::Value::String(s) => { - entries.push(BinEntry { - exe_path: s, - alias: None, - }); - } - serde_json::Value::Array(arr) => { - let exe_path = arr - .first() - .and_then(|v| v.as_str()) - .ok_or_else(|| { - de::Error::custom("bin array entry must have an exe path") - })? - .to_owned(); - let alias = arr.get(1).and_then(|v| v.as_str()).map(|s| s.to_owned()); - entries.push(BinEntry { exe_path, alias }); - } - _ => { - return Err(de::Error::custom("unexpected bin entry type")); - } - } - } - - Ok(ScoopBin(entries)) - } - } +/// `uninstaller`: uninstallation configuration. +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(default)] +pub struct Uninstaller { + pub args: Option, + pub file: Option, + pub script: Option, +} + +// --------------------------------------------------------------------------- +// Checkver +// --------------------------------------------------------------------------- - deserializer.deserialize_any(ScoopBinVisitor) +/// `checkver`: a regex string or an object with version-check configuration. +/// +/// Schema: `anyOf: [string, object]` +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum Checkver { + Regex(String), + Config(CheckverConfig), +} + +impl Default for Checkver { + fn default() -> Self { + Self::Config(CheckverConfig::default()) } } -/// Per-architecture download configuration. -#[derive(Debug, Clone, Deserialize)] -pub struct ScoopArchEntry { - pub url: Option, - pub hash: Option, - pub extract_dir: Option, - pub bin: Option, +/// Object form of `checkver`. +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(default)] +pub struct CheckverConfig { + pub github: Option, + #[serde(alias = "re")] + pub regex: Option, + pub url: Option, + #[serde(alias = "jp")] + pub jsonpath: Option, + pub xpath: Option, + pub reverse: Option, + pub replace: Option, + pub useragent: Option, + pub script: Option, + pub sourceforge: Option, +} + +// --------------------------------------------------------------------------- +// PSModule +// --------------------------------------------------------------------------- + +/// `psmodule`: PowerShell module installation. +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(default)] +pub struct PsModule { + pub name: Option, +} + +// --------------------------------------------------------------------------- +// Architecture entry (per 32bit / 64bit / arm64) +// --------------------------------------------------------------------------- + +/// `architecture` (definition): per-architecture overrides. +/// +/// Contains the same download/install fields as the top-level manifest but +/// scoped to a specific architecture. +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(default)] +pub struct ArchitectureEntry { + pub bin: Option, + pub checkver: Option, pub env_add_path: Option, + pub env_set: Option>, + pub extract_dir: Option, + pub hash: Option, + pub installer: Option, + pub post_install: Option, + pub post_uninstall: Option, + pub pre_install: Option, + pub pre_uninstall: Option, + pub shortcuts: Option, + pub uninstaller: Option, + pub url: Option, } -/// Architecture-specific overrides. -#[derive(Debug, Clone, Deserialize)] -pub struct ScoopArchitecture { +/// Top-level `architecture` property: maps arch names to entries. +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(default)] +pub struct Architecture { #[serde(rename = "32bit")] - pub x32: Option, + pub x86: Option, #[serde(rename = "64bit")] - pub x64: Option, - pub arm64: Option, + pub x64: Option, + pub arm64: Option, } -impl ScoopArchitecture { - pub fn get_entry(&self, arch: &str) -> Option<&ScoopArchEntry> { +impl Architecture { + pub fn get_entry(&self, arch: &str) -> Option<&ArchitectureEntry> { match arch { - "32bit" => self.x32.as_ref(), - "64bit" => self.x64.as_ref(), + "x86" | "32bit" => self.x86.as_ref(), + "x64" | "64bit" => self.x64.as_ref(), "arm64" => self.arm64.as_ref(), _ => None, } } } -/// Autoupdate hash configuration. -#[derive(Debug, Clone, Deserialize)] -#[serde(untagged)] -pub enum ScoopAutoupdateHash { - Url(String), - Config { - url: Option, - regex: Option, - jsonpath: Option, - }, -} +// --------------------------------------------------------------------------- +// Autoupdate +// --------------------------------------------------------------------------- -/// Autoupdate architecture entry. -#[derive(Debug, Clone, Deserialize)] -pub struct ScoopAutoupdateArchEntry { - pub url: Option, - pub hash: Option, +/// `autoupdateArch`: per-architecture autoupdate fields. +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(default)] +pub struct AutoupdateArchEntry { + pub bin: Option, + pub env_add_path: Option, + pub env_set: Option>, pub extract_dir: Option, + pub hash: Option>, + pub installer: Option, + pub shortcuts: Option, + pub url: Option, } -/// Autoupdate architecture. -#[derive(Debug, Clone, Deserialize)] -pub struct ScoopAutoupdateArchitecture { +/// Autoupdate installer (only has `file`). +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(default)] +pub struct AutoupdateInstaller { + pub file: Option, +} + +/// Autoupdate architecture map. +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(default)] +pub struct AutoupdateArchitecture { #[serde(rename = "32bit")] - pub x32: Option, + pub x86: Option, #[serde(rename = "64bit")] - pub x64: Option, - pub arm64: Option, + pub x64: Option, + pub arm64: Option, } -impl ScoopAutoupdateArchitecture { - pub fn get_entry(&self, arch: &str) -> Option<&ScoopAutoupdateArchEntry> { +impl AutoupdateArchitecture { + pub fn get_entry(&self, arch: &str) -> Option<&AutoupdateArchEntry> { match arch { - "32bit" => self.x32.as_ref(), - "64bit" => self.x64.as_ref(), + "x86" | "32bit" => self.x86.as_ref(), + "x64" | "64bit" => self.x64.as_ref(), "arm64" => self.arm64.as_ref(), _ => None, } } } -/// Autoupdate configuration. -#[derive(Debug, Clone, Deserialize)] -pub struct ScoopAutoupdate { - pub url: Option, - pub hash: Option, +/// `autoupdate`: top-level autoupdate configuration. +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(default)] +pub struct Autoupdate { + pub architecture: Option, + pub bin: Option, + pub env_add_path: Option, + pub env_set: Option>, pub extract_dir: Option, - pub architecture: Option, + pub hash: Option>, + pub installer: Option, + pub license: Option, + pub notes: Option, + pub persist: Option, + pub psmodule: Option, + pub shortcuts: Option, + pub url: Option, } +// --------------------------------------------------------------------------- +// Top-level manifest +// --------------------------------------------------------------------------- + /// A Scoop app manifest. +/// /// https://github.com/ScoopInstaller/Scoop/wiki/App-Manifests -#[derive(Debug, Clone, Deserialize)] +/// https://github.com/ScoopInstaller/Scoop/blob/master/schema.json +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(default)] pub struct ScoopManifest { + // -- Required fields --------------------------------------------------- pub version: String, + pub homepage: String, + pub license: License, + + // -- Optional metadata ------------------------------------------------- pub description: Option, - pub homepage: Option, - pub license: Option, + + // -- Download / extraction --------------------------------------------- pub url: Option, - pub hash: Option, + pub hash: Option, pub extract_dir: Option, - pub architecture: Option, - pub bin: Option, + pub extract_to: Option, + + // -- Architecture overrides -------------------------------------------- + pub architecture: Option, + + // -- Executables & environment ----------------------------------------- + pub bin: Option, pub env_add_path: Option, pub env_set: Option>, - pub persist: Option, - pub autoupdate: Option, + pub shortcuts: Option, + + // -- Install / uninstall hooks ----------------------------------------- + pub installer: Option, + pub uninstaller: Option, + pub pre_install: Option, + pub post_install: Option, + pub pre_uninstall: Option, + pub post_uninstall: Option, + + // -- Persistence & dependencies ---------------------------------------- + pub persist: Option, + pub depends: Option, + + // -- Version checking / auto-update ------------------------------------ + pub checkver: Option, + pub autoupdate: Option, +} + +// --------------------------------------------------------------------------- +// Convenience helpers used by proto.rs +// --------------------------------------------------------------------------- + +/// A resolved executable entry extracted from the `bin` field. +#[derive(Debug, Clone)] +pub struct BinEntry { + /// Path to the executable (e.g. `"node.exe"` or `"bin\\node.exe"`). + pub exe_path: String, + /// Optional alias / shim name (e.g. `"node"`). + pub alias: Option, } impl ScoopManifest { + // -- Field resolution with architecture fallback ----------------------- + /// Get the download URL for a given architecture. /// Falls back to the top-level `url` if no architecture-specific URL exists. pub fn get_url_for_arch(&self, arch: &str) -> Option { @@ -222,7 +399,7 @@ impl ScoopManifest { .map(|s| s.to_owned()) } - /// Get the SHA256 hash for a given architecture. + /// Get the hash for a given architecture. pub fn get_hash_for_arch(&self, arch: &str) -> Option { if let Some(architecture) = &self.architecture && let Some(entry) = architecture.get_entry(arch) @@ -253,30 +430,61 @@ impl ScoopManifest { /// Get executable entries for a given architecture. /// Falls back to the top-level `bin` if no architecture-specific bin exists. pub fn get_executables_for_arch(&self, arch: &str) -> Vec { - if let Some(architecture) = &self.architecture - && let Some(entry) = architecture.get_entry(arch) - && let Some(bin) = &entry.bin - { - return bin.0.clone(); - } - self.bin.as_ref().map(|b| b.0.clone()).unwrap_or_default() + // let raw = if let Some(architecture) = &self.architecture + // && let Some(entry) = architecture.get_entry(arch) + // && let Some(bin) = &entry.bin + // { + // &bin.0 + // } else if let Some(bin) = &self.bin { + // &bin.0 + // } else { + // return Vec::new(); + // }; + + // raw.iter() + // .map(|parts| BinEntry { + // exe_path: parts.first().cloned().unwrap_or_default(), + // alias: parts.get(1).cloned(), + // }) + // .collect() + + vec![] } - /// Get env_add_path entries for a given architecture. + /// Get `env_add_path` entries for a given architecture. pub fn get_env_add_paths_for_arch(&self, arch: &str) -> Vec { + // if let Some(architecture) = &self.architecture + // && let Some(entry) = architecture.get_entry(arch) + // && let Some(paths) = &entry.env_add_path + // { + // return paths.0.clone(); + // } + // self.env_add_path + // .as_ref() + // .map(|p| p.0.clone()) + // .unwrap_or_default() + + vec![] + } + + /// Get `env_set` entries for a given architecture. + pub fn get_env_set_for_arch(&self, arch: &str) -> HashMap { if let Some(architecture) = &self.architecture && let Some(entry) = architecture.get_entry(arch) - && let Some(paths) = &entry.env_add_path + && let Some(env) = &entry.env_set { - return paths.0.clone(); + return env.clone(); } - self.env_add_path - .as_ref() - .map(|p| p.0.clone()) - .unwrap_or_default() + self.env_set.clone().unwrap_or_default() } - /// Substitute version placeholders in a URL template. + // -- Version template substitution ------------------------------------- + + /// Substitute version placeholders in a template string. + /// + /// Supported placeholders: + /// `$version`, `$majorVersion`, `$minorVersion`, `$patchVersion`, + /// `$buildVersion`. pub fn substitute_version(template: &str, version: &str) -> String { let mut result = template.replace("$version", version); @@ -297,8 +505,8 @@ impl ScoopManifest { result } - /// Substitute version placeholders, also resolving `$baseurl` from the manifest's - /// current download URL. + /// Substitute version placeholders, also resolving `$baseurl` from the + /// manifest's current download URL for the given architecture. pub fn substitute_version_with_baseurl( &self, template: &str, @@ -318,6 +526,8 @@ impl ScoopManifest { result } + // -- Autoupdate resolution --------------------------------------------- + /// Resolve the autoupdate URL for a given version and architecture. pub fn resolve_autoupdate_url(&self, version: &str, arch: &str) -> Option { let autoupdate = self.autoupdate.as_ref()?; @@ -340,7 +550,7 @@ impl ScoopManifest { .map(|t| self.substitute_version_with_baseurl(t, version, arch)) } - /// Resolve the autoupdate extract_dir for a given version and architecture. + /// Resolve the autoupdate `extract_dir` for a given version and architecture. pub fn resolve_autoupdate_extract_dir(&self, version: &str, arch: &str) -> Option { let autoupdate = self.autoupdate.as_ref()?; diff --git a/backends/scoop/src/proto.rs b/backends/scoop/src/proto.rs index ae1dc576..c07cbe26 100644 --- a/backends/scoop/src/proto.rs +++ b/backends/scoop/src/proto.rs @@ -57,18 +57,8 @@ pub fn define_tool_config(_: ()) -> FnResult> { #[plugin_fn] pub fn register_backend( - Json(input): Json, + Json(_input): Json, ) -> FnResult> { - let env = get_host_environment()?; - - if !env.os.is_windows() { - return Err(PluginError::UnsupportedOS { - tool: input.id.to_string(), - os: env.os.to_string(), - } - .into()); - } - let config = get_tool_config::()?; Ok(Json(RegisterBackendOutput { @@ -246,12 +236,16 @@ pub fn pre_run(Json(_): Json) -> FnResult> { let config = get_tool_config::()?; let manifest = fetch_manifest(&config)?; - if let Some(env_set) = manifest.env_set { - let mut env = FxHashMap::default(); + let env = get_host_environment()?; + let arch = map_arch(env.arch); + let env_set = manifest.get_env_set_for_arch(arch); + + if !env_set.is_empty() { + let mut env_map = FxHashMap::default(); for (key, value) in env_set { - env.insert(key, value); + env_map.insert(key, value); } - output.env = Some(env); + output.env = Some(env_map); } Ok(Json(output)) From 85da9b30bfa2e4d768d27c5dd013f81f23fba7bd Mon Sep 17 00:00:00 2001 From: Miles Johnson Date: Sat, 11 Apr 2026 16:32:55 -0700 Subject: [PATCH 3/3] Update manifest. --- backends/scoop/src/manifest.rs | 36 +++++++++++++++++----------------- backends/scoop/src/proto.rs | 12 +++++++++++- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/backends/scoop/src/manifest.rs b/backends/scoop/src/manifest.rs index adced97e..7f4087f7 100644 --- a/backends/scoop/src/manifest.rs +++ b/backends/scoop/src/manifest.rs @@ -3,14 +3,14 @@ //! Derived from the JSON schema at: //! https://github.com/ScoopInstaller/Scoop/blob/master/schema.json -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; // --------------------------------------------------------------------------- // Primitive union types (from schema definitions) // --------------------------------------------------------------------------- -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(untagged)] pub enum OneOrMany { One(T1), @@ -69,7 +69,7 @@ pub type ShortcutsArray = Vec>; /// /// Schema: object with optional `find`, `regex`, `jp`, `jsonpath`, `xpath`, /// `mode`, `type`, `url`. -#[derive(Debug, Default, Clone, Deserialize)] +#[derive(Debug, Default, Clone, Deserialize, Serialize)] #[serde(default)] pub struct HashExtraction { #[serde(alias = "find")] @@ -84,7 +84,7 @@ pub struct HashExtraction { } /// Extraction modes for `hashExtraction.mode`. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] pub enum HashExtractionMode { Download, @@ -98,7 +98,7 @@ pub enum HashExtractionMode { } /// Hash algorithm types (deprecated in schema). -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] pub enum HashType { Md5, @@ -114,7 +114,7 @@ pub enum HashType { /// `license`: either an SPDX identifier string or `{ identifier, url? }`. /// /// Schema: `anyOf: [licenseIdentifiers, { identifier: string, url?: string }]` -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(untagged)] pub enum License { Identifier(String), @@ -135,7 +135,7 @@ impl Default for License { // --------------------------------------------------------------------------- /// `installer`: installation configuration. -#[derive(Debug, Default, Clone, Deserialize)] +#[derive(Debug, Default, Clone, Deserialize, Serialize)] #[serde(default)] pub struct Installer { pub args: Option, @@ -145,7 +145,7 @@ pub struct Installer { } /// `uninstaller`: uninstallation configuration. -#[derive(Debug, Default, Clone, Deserialize)] +#[derive(Debug, Default, Clone, Deserialize, Serialize)] #[serde(default)] pub struct Uninstaller { pub args: Option, @@ -160,7 +160,7 @@ pub struct Uninstaller { /// `checkver`: a regex string or an object with version-check configuration. /// /// Schema: `anyOf: [string, object]` -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(untagged)] pub enum Checkver { Regex(String), @@ -174,7 +174,7 @@ impl Default for Checkver { } /// Object form of `checkver`. -#[derive(Debug, Default, Clone, Deserialize)] +#[derive(Debug, Default, Clone, Deserialize, Serialize)] #[serde(default)] pub struct CheckverConfig { pub github: Option, @@ -196,7 +196,7 @@ pub struct CheckverConfig { // --------------------------------------------------------------------------- /// `psmodule`: PowerShell module installation. -#[derive(Debug, Default, Clone, Deserialize)] +#[derive(Debug, Default, Clone, Deserialize, Serialize)] #[serde(default)] pub struct PsModule { pub name: Option, @@ -210,7 +210,7 @@ pub struct PsModule { /// /// Contains the same download/install fields as the top-level manifest but /// scoped to a specific architecture. -#[derive(Debug, Default, Clone, Deserialize)] +#[derive(Debug, Default, Clone, Deserialize, Serialize)] #[serde(default)] pub struct ArchitectureEntry { pub bin: Option, @@ -230,7 +230,7 @@ pub struct ArchitectureEntry { } /// Top-level `architecture` property: maps arch names to entries. -#[derive(Debug, Default, Clone, Deserialize)] +#[derive(Debug, Default, Clone, Deserialize, Serialize)] #[serde(default)] pub struct Architecture { #[serde(rename = "32bit")] @@ -256,7 +256,7 @@ impl Architecture { // --------------------------------------------------------------------------- /// `autoupdateArch`: per-architecture autoupdate fields. -#[derive(Debug, Default, Clone, Deserialize)] +#[derive(Debug, Default, Clone, Deserialize, Serialize)] #[serde(default)] pub struct AutoupdateArchEntry { pub bin: Option, @@ -270,14 +270,14 @@ pub struct AutoupdateArchEntry { } /// Autoupdate installer (only has `file`). -#[derive(Debug, Default, Clone, Deserialize)] +#[derive(Debug, Default, Clone, Deserialize, Serialize)] #[serde(default)] pub struct AutoupdateInstaller { pub file: Option, } /// Autoupdate architecture map. -#[derive(Debug, Default, Clone, Deserialize)] +#[derive(Debug, Default, Clone, Deserialize, Serialize)] #[serde(default)] pub struct AutoupdateArchitecture { #[serde(rename = "32bit")] @@ -299,7 +299,7 @@ impl AutoupdateArchitecture { } /// `autoupdate`: top-level autoupdate configuration. -#[derive(Debug, Default, Clone, Deserialize)] +#[derive(Debug, Default, Clone, Deserialize, Serialize)] #[serde(default)] pub struct Autoupdate { pub architecture: Option, @@ -325,7 +325,7 @@ pub struct Autoupdate { /// /// https://github.com/ScoopInstaller/Scoop/wiki/App-Manifests /// https://github.com/ScoopInstaller/Scoop/blob/master/schema.json -#[derive(Debug, Default, Clone, Deserialize)] +#[derive(Debug, Default, Clone, Deserialize, Serialize)] #[serde(default)] pub struct ScoopManifest { // -- Required fields --------------------------------------------------- diff --git a/backends/scoop/src/proto.rs b/backends/scoop/src/proto.rs index c07cbe26..058f6592 100644 --- a/backends/scoop/src/proto.rs +++ b/backends/scoop/src/proto.rs @@ -21,8 +21,18 @@ fn map_arch(arch: HostArch) -> &'static str { fn fetch_manifest(config: &ScoopToolConfig) -> AnyResult { let url = config.get_manifest_url()?; + let cache_key = format!("manifest-{url}"); + + if let Some(cache) = var::get::(&cache_key)? { + let manifest: ScoopManifest = json::from_str(&cache)?; + + return Ok(manifest); + } + let manifest: ScoopManifest = fetch_json(&url)?; + var::set(cache_key, json::to_string(&manifest)?)?; + Ok(manifest) } @@ -115,7 +125,7 @@ pub fn download_prebuilt( let env = get_host_environment()?; check_supported_os_and_arch( - "Scoop", + "scoop", &env, permutations![ HostOS::Windows => [HostArch::X64, HostArch::X86, HostArch::Arm64],