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..7f4087f7 --- /dev/null +++ b/backends/scoop/src/manifest.rs @@ -0,0 +1,570 @@ +//! 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, Serialize}; +use std::collections::HashMap; + +// --------------------------------------------------------------------------- +// Primitive union types (from schema definitions) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub enum OneOrMany { + One(T1), + Many(Vec), +} + +impl OneOrMany { + pub fn first(&self) -> Option<&T1> { + match self { + Self::One(inner) => Some(inner), + Self::Many(_) => None, + } + } +} + +impl Default for OneOrMany { + fn default() -> Self { + Self::Many(vec![]) + } +} + +/// `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, Serialize)] +#[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, +} + +/// Extraction modes for `hashExtraction.mode`. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum HashExtractionMode { + Download, + Extract, + Json, + Xpath, + Rdf, + Metalink, + Fosshub, + SourceForge, +} + +/// Hash algorithm types (deprecated in schema). +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum HashType { + Md5, + Sha1, + Sha256, + Sha512, +} + +// --------------------------------------------------------------------------- +// License +// --------------------------------------------------------------------------- + +/// `license`: either an SPDX identifier string or `{ identifier, url? }`. +/// +/// Schema: `anyOf: [licenseIdentifiers, { identifier: string, url?: string }]` +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub enum License { + Identifier(String), + Full { + identifier: String, + url: Option, + }, +} + +impl Default for License { + fn default() -> Self { + Self::Identifier("mit".into()) + } +} + +// --------------------------------------------------------------------------- +// Installer / Uninstaller +// --------------------------------------------------------------------------- + +/// `installer`: installation configuration. +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +#[serde(default)] +pub struct Installer { + pub args: Option, + pub file: Option, + pub script: Option, + pub keep: Option, +} + +/// `uninstaller`: uninstallation configuration. +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +#[serde(default)] +pub struct Uninstaller { + pub args: Option, + pub file: Option, + pub script: Option, +} + +// --------------------------------------------------------------------------- +// Checkver +// --------------------------------------------------------------------------- + +/// `checkver`: a regex string or an object with version-check configuration. +/// +/// Schema: `anyOf: [string, object]` +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub enum Checkver { + Regex(String), + Config(CheckverConfig), +} + +impl Default for Checkver { + fn default() -> Self { + Self::Config(CheckverConfig::default()) + } +} + +/// Object form of `checkver`. +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +#[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, Serialize)] +#[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, Serialize)] +#[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, +} + +/// Top-level `architecture` property: maps arch names to entries. +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +#[serde(default)] +pub struct Architecture { + #[serde(rename = "32bit")] + pub x86: Option, + #[serde(rename = "64bit")] + pub x64: Option, + pub arm64: Option, +} + +impl Architecture { + pub fn get_entry(&self, arch: &str) -> Option<&ArchitectureEntry> { + match arch { + "x86" | "32bit" => self.x86.as_ref(), + "x64" | "64bit" => self.x64.as_ref(), + "arm64" => self.arm64.as_ref(), + _ => None, + } + } +} + +// --------------------------------------------------------------------------- +// Autoupdate +// --------------------------------------------------------------------------- + +/// `autoupdateArch`: per-architecture autoupdate fields. +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +#[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 installer (only has `file`). +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +#[serde(default)] +pub struct AutoupdateInstaller { + pub file: Option, +} + +/// Autoupdate architecture map. +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +#[serde(default)] +pub struct AutoupdateArchitecture { + #[serde(rename = "32bit")] + pub x86: Option, + #[serde(rename = "64bit")] + pub x64: Option, + pub arm64: Option, +} + +impl AutoupdateArchitecture { + pub fn get_entry(&self, arch: &str) -> Option<&AutoupdateArchEntry> { + match arch { + "x86" | "32bit" => self.x86.as_ref(), + "x64" | "64bit" => self.x64.as_ref(), + "arm64" => self.arm64.as_ref(), + _ => None, + } + } +} + +/// `autoupdate`: top-level autoupdate configuration. +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +#[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 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 +/// https://github.com/ScoopInstaller/Scoop/blob/master/schema.json +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +#[serde(default)] +pub struct ScoopManifest { + // -- Required fields --------------------------------------------------- + pub version: String, + pub homepage: String, + pub license: License, + + // -- Optional metadata ------------------------------------------------- + pub description: Option, + + // -- Download / extraction --------------------------------------------- + pub url: Option, + pub hash: Option, + pub extract_dir: 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 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 { + 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 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 { + // 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. + 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(env) = &entry.env_set + { + return env.clone(); + } + self.env_set.clone().unwrap_or_default() + } + + // -- 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); + + 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 for the given architecture. + 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 + } + + // -- 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()?; + + // 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..058f6592 --- /dev/null +++ b/backends/scoop/src/proto.rs @@ -0,0 +1,262 @@ +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 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) +} + +#[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 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)?; + + 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_map.insert(key, value); + } + output.env = Some(env_map); + } + + 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")); + } +}