diff --git a/Cargo.lock b/Cargo.lock index 71c7ce1d..4e8b8f3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1984,7 +1984,7 @@ dependencies = [ [[package]] name = "go_toolchain" -version = "1.4.5" +version = "1.4.7" dependencies = [ "extism-pdk", "go_tool", @@ -2595,7 +2595,7 @@ dependencies = [ [[package]] name = "javascript_toolchain" -version = "1.2.2" +version = "1.3.0" dependencies = [ "deno_lockfile", "extism-pdk", @@ -3159,9 +3159,9 @@ dependencies = [ [[package]] name = "moon_pdk_api" -version = "2.1.0" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17e60b6592bdd68319ce9c0b5874e6cf67b7dd2cbdb4a72ca75de8232a2186b6" +checksum = "e85fa8551e29ac31db1c19d4914a101328a8c57cc4fa5d1db3ed402899bb97fc" dependencies = [ "derive_setters", "moon_common", @@ -3294,7 +3294,7 @@ dependencies = [ [[package]] name = "node_depman_toolchain" -version = "1.0.4" +version = "1.1.0" dependencies = [ "extism-pdk", "moon_config", @@ -3332,7 +3332,7 @@ dependencies = [ [[package]] name = "node_toolchain" -version = "1.0.3" +version = "1.0.4" dependencies = [ "extism-pdk", "moon_common", @@ -4090,7 +4090,7 @@ dependencies = [ [[package]] name = "python_pip_toolchain" -version = "0.1.3" +version = "0.1.4" dependencies = [ "extism-pdk", "moon_config", @@ -4117,7 +4117,7 @@ dependencies = [ [[package]] name = "python_poetry_toolchain" -version = "0.1.1" +version = "0.1.2" dependencies = [ "extism-pdk", "moon_config", @@ -4144,7 +4144,7 @@ dependencies = [ [[package]] name = "python_toolchain" -version = "0.2.1" +version = "0.3.0" dependencies = [ "extism-pdk", "moon_common", @@ -4181,7 +4181,7 @@ dependencies = [ [[package]] name = "python_uv_toolchain" -version = "0.1.4" +version = "0.1.5" dependencies = [ "extism-pdk", "moon_config", @@ -4667,7 +4667,7 @@ dependencies = [ [[package]] name = "rust_toolchain" -version = "1.0.8" +version = "1.0.9" dependencies = [ "cargo-lock", "cargo_toml", diff --git a/Cargo.toml b/Cargo.toml index 8c32a678..7b415e82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ toml = { version = "1.1.4", default-features = false, features = [ moon_common = { version = "2.0.8" } moon_config = { version = "2.1.1" } moon_pdk = { version = "2.1.0" } -moon_pdk_api = { version = "2.1.0" } +moon_pdk_api = { version = "2.1.2" } moon_pdk_test_utils = { version = "2.1.0" } moon_project = { version = "2.0.7" } moon_target = { version = "3.0.1" } diff --git a/toolchains/go/CHANGELOG.md b/toolchains/go/CHANGELOG.md index b3188a68..dcaa9049 100644 --- a/toolchains/go/CHANGELOG.md +++ b/toolchains/go/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog +## Unreleased + +#### 🚀 Updates + +- Reworked relationship inference to match package import paths instead of module paths. Each project now resolves a canonical import path (nearest `go.mod` module path plus the project's relative directory), and `go list -deps` results are matched against those by longest prefix. This makes relationships resolvable in repositories that share a single `go.mod` across all projects. +- Sibling modules required by version without a `go.work` no longer create project relationships, since those builds consume the published module rather than the local source. When the `go` binary is unavailable, projects with their own `go.mod` under a workspace `go.work` fall back to resolving relationships from their direct requires. +- `replace` directives keep their meaning in the new model: a require replaced by a local directory always links to the project at that location (it consumes local source even without a `go.work`), while a require replaced by another module never links. +- Imports within a project's own import path are treated as ownership rather than dependencies. `go list -deps ./...` enumerates packages belonging to projects nested inside the scanned project, which previously inferred an edge from the parent to every nested child — forming a cycle whenever a child declared `dependsOn` on its parent. + +## 1.4.7 + +#### 🐞 Fixes + +- Fixed project relationships not linking when a `go.mod` is located in a major + version folder (`v2`+) but its `module` directive omits the version suffix. + The suffix is now inferred from the folder name, for both the project's alias + and dependency matching. +- `replace` directives are now honored when linking project relationships. A + replacement pointing to a local directory links to the project at that + location (regardless of module names), while a replacement pointing to + another module no longer creates a relationship. +- Project relationships now reference the dependency's project identifier + instead of its module path alias, aligning with other toolchains. + +## 1.4.6 + +#### 🐞 Fixes + +- Fixed configured `bins` not being reinstalled when their binaries were + uninstalled or deleted outside of moon. Missing binaries are now detected in + the globals directory, and only missing binaries are installed. Binaries + pinned to a version, branch, or commit are always installed, as their + installed version cannot be verified. +- The `force` option for `bins` entries is now respected, and will always + install the binary. + ## 1.4.5 #### 🚀 Updates diff --git a/toolchains/go/Cargo.toml b/toolchains/go/Cargo.toml index 3be17187..6c629d43 100644 --- a/toolchains/go/Cargo.toml +++ b/toolchains/go/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "go_toolchain" -version = "1.4.5" +version = "1.4.7" edition = "2024" description = "Go toolchain WASM plugin for moon." authors = ["Miles Johnson"] diff --git a/toolchains/go/src/lib.rs b/toolchains/go/src/lib.rs index 0f2d4748..6a349766 100644 --- a/toolchains/go/src/lib.rs +++ b/toolchains/go/src/lib.rs @@ -3,6 +3,8 @@ pub mod go_mod; pub mod go_sum; pub mod go_work; +#[cfg(feature = "wasm")] +mod package_graph; #[cfg(feature = "wasm")] mod tier1; #[cfg(feature = "wasm")] diff --git a/toolchains/go/src/package_graph.rs b/toolchains/go/src/package_graph.rs new file mode 100644 index 00000000..5c64ddbe --- /dev/null +++ b/toolchains/go/src/package_graph.rs @@ -0,0 +1,483 @@ +use crate::config::GoToolchainConfig; +use crate::go_mod::{GoMod, ModuleReplacement, Replacement, parse_go_mod}; +use moon_config::DependencyScope; +use moon_pdk::exec; +use moon_pdk_api::*; +use starbase_utils::fs; +use std::collections::{BTreeMap, BTreeSet}; + +// The `go list` half of the package graph: every package a directory's +// packages depend on, as canonical import paths. +fn execute_go_list(dir: &VirtualPath, packages: &[String], test: bool) -> AnyResult> { + let mut args = vec![ + "list", + "-deps", + "-f", + "{{if .Module}}{{.ImportPath}}{{end}}", + ]; + + if test { + args.push("-test"); + } + + // Scan all packages recursively by default so that dependencies imported + // only from subdirectories (internal/, pkg/, ...) are also inferred. + if packages.is_empty() { + args.push("./..."); + } else { + for package in packages { + args.push(package.as_str()); + } + } + + let result = exec(ExecCommandInput::pipe("go", args).cwd(dir.to_owned()))?; + + if result.exit_code != 0 { + return Ok(vec![]); + } + + Ok(result + .stdout + .lines() + .filter_map(|line| { + // Test binary pseudo-packages render as `pkg [pkg.test]`; + // only the real package path participates in matching. + let import_path = line.trim().split(' ').next().unwrap_or_default(); + + (!import_path.is_empty()).then(|| import_path.to_owned()) + }) + .collect()) +} + +// Whether `import_path` is the package at `prefix` itself, or nested beneath +// it. +fn import_within(import_path: &str, prefix: &str) -> bool { + import_path == prefix + || import_path + .strip_prefix(prefix) + .is_some_and(|rest| rest.starts_with('/')) +} + +// Workspace-relative dirs that may own a directory's `go.mod`: the dir +// itself, then each ancestor up to the workspace root (""). +fn module_dir_candidates(source: &str) -> Vec<&str> { + let mut candidates = vec![]; + let mut dir = source; + + while !dir.is_empty() { + candidates.push(dir); + dir = dir.rfind('/').map_or("", |index| &dir[..index]); + } + + candidates.push(""); + candidates +} + +// Resolves workspace directories to the import paths their packages live +// under, caching each `go.mod` it parses along the way. A `None` cache entry +// memoizes "no usable `go.mod` here", so ancestors shared between +// directories only hit the disk once. +struct ModuleResolver { + workspace_root: VirtualPath, + go_mods: BTreeMap>, +} + +impl ModuleResolver { + fn new(workspace_root: VirtualPath) -> Self { + Self { + workspace_root, + go_mods: BTreeMap::default(), + } + } + + // The canonical import path of a workspace-relative dir: the module path + // of the nearest `go.mod` at or above it (bounded by the workspace + // root), joined with the dir's path relative to that module root. + // Returns the owning dir alongside so callers can key back into the + // cache. + fn import_path(&mut self, source: &str) -> AnyResult> { + for dir in module_dir_candidates(source) { + if !self.go_mods.contains_key(dir) { + let go_mod_path = self.go_mod_path(dir); + + let manifest = if go_mod_path.exists() { + Some(parse_go_mod(fs::read_file(&go_mod_path)?)?) + .filter(|manifest| !manifest.module.is_empty()) + .map(|mut manifest| { + // A module in a major version folder (v2+) is + // imported with the version suffix, even when the + // `module` directive omits it + // https://go.dev/ref/mod#major-version-suffixes + if let Some(version) = dir + .rsplit('/') + .next() + .filter(|segment| is_version_segment(segment)) + && !is_version_segment( + manifest.module.rsplit('/').next().unwrap_or_default(), + ) + { + manifest.module = format!("{}/{version}", manifest.module); + } + + manifest + }) + } else { + None + }; + + self.go_mods.insert(dir.to_owned(), manifest); + } + + if let Some(manifest) = self.go_mods.get(dir).and_then(|entry| entry.as_ref()) { + let relative = source[dir.len()..].trim_start_matches('/'); + + let import_path = if relative.is_empty() { + manifest.module.clone() + } else { + format!("{}/{relative}", manifest.module) + }; + + return Ok(Some((dir.to_owned(), import_path))); + } + } + + Ok(None) + } + + // The parsed manifest owned by a dir previously resolved through + // `import_path`, with any major version suffix already applied to its + // module path. + fn manifest(&self, dir: &str) -> Option<&GoMod> { + self.go_mods.get(dir).and_then(|entry| entry.as_ref()) + } + + fn go_mod_path(&self, dir: &str) -> VirtualPath { + if dir.is_empty() { + self.workspace_root.join("go.mod") + } else { + self.workspace_root.join(dir).join("go.mod") + } + } +} + +pub struct GoProject { + pub id: Id, + /// Module path as declared when the project owns its `go.mod` + pub alias: Option, + root: VirtualPath, + import_path: Option, + /// Direct module requires from the project's own `go.mod` + requires: Vec, +} + +struct GoRequire { + module_path: String, + target: GoRequireTarget, +} + +// What a `require` actually resolves to once `replace` directives are +// applied, which determines whether it can link to a local project. +enum GoRequireTarget { + /// Required by version; only links when the environment wires the + /// module to local source (a `go.work` workspace) + Module, + /// Replaced by a local directory (workspace-relative), so it always + /// consumes local source + LocalSource(String), + /// Replaced by another module, or a path outside the workspace, so it + /// can never be a local project + External, +} + +// All the state relationship inference works from: the resolved projects, +// the import-path prefix map they resolve against, and the module resolver +// used to build both. +pub struct GoPackageGraph { + workspace_root: VirtualPath, + config: GoToolchainConfig, + go_exists: bool, + resolver: ModuleResolver, + projects: Vec, + /// Project lookup by workspace-relative source dir, for `replace` + /// directives that point at local directories + source_to_id: BTreeMap, + /// Import path each project resolves to, matched by prefix + package_prefixes: Vec<(String, Id)>, + /// The `go.mod` files backing the resolved import paths + input_files: Vec, +} + +impl GoPackageGraph { + pub fn new(workspace_root: VirtualPath, config: GoToolchainConfig, go_exists: bool) -> Self { + Self { + resolver: ModuleResolver::new(workspace_root.clone()), + workspace_root, + config, + go_exists, + projects: vec![], + source_to_id: BTreeMap::default(), + package_prefixes: vec![], + input_files: vec![], + } + } + + // First pass: resolve every project to its import path and manifest, and + // build the prefix map that imports are resolved against. + pub fn load_projects(&mut self, sources: BTreeMap) -> AnyResult<()> { + for (id, source) in sources { + let root = self.workspace_root.join(&source); + let source = if source == "." { "" } else { source.as_str() }; + + self.source_to_id.insert(source.to_owned(), id.clone()); + + let mut project = GoProject { + id, + root, + alias: None, + import_path: None, + requires: vec![], + }; + + if let Some((mod_dir, import_path)) = self.resolver.import_path(source)? { + let go_mod_path = self.resolver.go_mod_path(&mod_dir); + + if !self.input_files.contains(&go_mod_path) { + self.input_files.push(go_mod_path); + } + + // An ancestor's requires describe the whole module, so they + // only participate for the project that owns the `go.mod` + if mod_dir == source + && let Some(manifest) = self.resolver.manifest(&mod_dir) + { + if manifest.module != project.id.as_str() { + project.alias = Some(manifest.module.clone()); + } + + project.requires = manifest + .require + .iter() + .filter(|dep| !dep.indirect) + .map(|dep| { + let module_path = dep.module.module_path.clone(); + + // A `replace` directive changes what the require + // resolves to, so it takes precedence over + // matching import paths + let target = match manifest + .replace + .iter() + .find(|replacement| replacement.module_path == module_path) + { + Some(ModuleReplacement { + replacement: Replacement::FilePath(path), + .. + }) => resolve_source_path(source, path).map_or( + GoRequireTarget::External, + GoRequireTarget::LocalSource, + ), + Some(_) => GoRequireTarget::External, + None => GoRequireTarget::Module, + }; + + GoRequire { + module_path, + target, + } + }) + .collect(); + } + + project.import_path = Some(import_path); + } + + self.projects.push(project); + } + + self.package_prefixes = self + .projects + .iter() + .filter_map(|project| { + project + .import_path + .clone() + .map(|path| (path, project.id.clone())) + }) + .collect(); + + Ok(()) + } + + pub fn projects(&self) -> &[GoProject] { + &self.projects + } + + pub fn into_input_files(self) -> Vec { + self.input_files + } + + // Resolves an import path to the project whose import path prefixes it. + // The longest match wins, so the module root can't shadow projects + // nested beneath it. + fn resolve_import(&self, import_path: &str) -> Option<&Id> { + self.package_prefixes + .iter() + .filter(|(prefix, _)| import_within(import_path, prefix)) + .max_by_key(|(prefix, _)| prefix.len()) + .map(|(_, id)| id) + } + + // Second pass: infer one project's dependencies, picking the mechanism + // the environment supports. + pub fn project_dependencies(&self, project: &GoProject) -> AnyResult> { + let mut dependencies = vec![]; + + // A project without an import path may still sit under a `go.work`, + // where `go list` resolves imports in workspace mode + if self.go_exists + && (project.import_path.is_some() || project.root.join("go.work").exists()) + { + dependencies = self.dependencies_from_go_list(project)?; + } + + // Monorepos using go.work can be resolved purely off the modfile: the + // workspace wires required sibling modules to their local source, so + // a project's own requires reflect real local relationships. That + // only matters when `go` isn't around to resolve them properly, while + // requires replaced by a local directory always consume local source. + let include_unreplaced = !self.go_exists + && self.workspace_root.join("go.work").exists() + && project.root.join("go.mod").exists(); + + for dependency in self.dependencies_from_modfile(project, include_unreplaced) { + if !dependencies.iter().any(|dep| dep.id == dependency.id) { + dependencies.push(dependency); + } + } + + Ok(dependencies) + } + + fn dependencies_from_modfile( + &self, + project: &GoProject, + include_unreplaced: bool, + ) -> Vec { + let mut dependencies = vec![]; + let mut seen = BTreeSet::new(); + + for require in &project.requires { + let dep_id = match &require.target { + GoRequireTarget::LocalSource(path) => self.source_to_id.get(path), + GoRequireTarget::External => None, + GoRequireTarget::Module => include_unreplaced + .then(|| self.resolve_import(&require.module_path)) + .flatten(), + }; + + if let Some(dep_id) = dep_id + && dep_id != &project.id + && seen.insert(dep_id) + { + dependencies.push(ProjectDependency { + id: dep_id.to_owned(), + scope: DependencyScope::Production, + via: Some(format!("module {}", require.module_path)), + }); + } + } + + dependencies + } + + fn dependencies_from_go_list(&self, project: &GoProject) -> AnyResult> { + let mut dependencies = vec![]; + let mut seen = BTreeSet::new(); + + for (enabled, test, scope) in [ + ( + self.config.infer_relationships, + false, + DependencyScope::Production, + ), + ( + self.config.infer_relationships_from_tests, + true, + DependencyScope::Development, + ), + ] { + if !enabled { + continue; + } + + let imports = execute_go_list( + &project.root, + &self.config.infer_relationships_packages, + test, + )?; + + for import_path in imports { + // `./...` also enumerates packages belonging to projects + // nested inside this one; anything within the project's own + // import path is ownership, not an import + if let Some(own) = project.import_path.as_deref() + && import_within(&import_path, own) + { + continue; + } + + if let Some(dep_id) = self.resolve_import(&import_path) + && dep_id != &project.id + && seen.insert(dep_id) + { + dependencies.push(ProjectDependency { + id: dep_id.to_owned(), + scope, + via: Some(format!("package {import_path}")), + }); + } + } + } + + Ok(dependencies) + } +} + +// Lexically resolve a relative path (`./`, `../`) against a workspace +// relative source, returning `None` when it escapes the workspace +fn resolve_source_path(base: &str, path: &str) -> Option { + if path.starts_with('/') { + return None; + } + + let mut segments = base + .split('/') + .filter(|segment| !segment.is_empty() && *segment != ".") + .collect::>(); + + for segment in path.split('/') { + match segment { + "" | "." => {} + ".." => { + segments.pop()?; + } + _ => segments.push(segment), + } + } + + Some(segments.join("/")) +} + +// A major version suffix segment: v2, v3, etc, but never v0 or v1 +// https://go.dev/ref/mod#major-version-suffixes +pub fn is_version_segment(segment: &str) -> bool { + match segment.strip_prefix('v') { + Some(digits) => { + !digits.is_empty() + && !digits.starts_with('0') + && digits != "1" + && digits.chars().all(|c| c.is_ascii_digit()) + } + None => false, + } +} diff --git a/toolchains/go/src/tier2.rs b/toolchains/go/src/tier2.rs index d6dcf02f..aeab47f4 100644 --- a/toolchains/go/src/tier2.rs +++ b/toolchains/go/src/tier2.rs @@ -1,11 +1,12 @@ use crate::config::GoToolchainConfig; -use crate::go_mod::{GoMod, Module, ModuleDependency, parse_go_mod}; +use crate::go_mod::parse_go_mod; use crate::go_sum::GoSum; use crate::go_work::GoWork; +use crate::package_graph::{GoPackageGraph, is_version_segment}; use extism_pdk::*; -use moon_config::{BinEntry, DependencyScope}; +use moon_config::BinEntry; use moon_pdk::{ - VirtualPathExt, command_exists, exec, get_host_env_var, get_host_environment, locate_root, + VirtualPathExt, command_exists, get_host_env_var, get_host_environment, locate_root, parse_toolchain_config_schema, }; use moon_pdk_api::*; @@ -13,161 +14,41 @@ use starbase_utils::fs; use std::collections::BTreeMap; use std::path::PathBuf; -fn is_go_project(dir: &VirtualPath) -> bool { - dir.join("go.mod").exists() - || dir.join("go.sum").exists() - || dir.join("go.work").exists() - || dir.join("main.go").exists() -} - -fn execute_go_list( - dir: &VirtualPath, - packages: &[String], - test: bool, -) -> AnyResult> { - let mut args = vec![ - "list", - "-deps", - "-f", - "{{if .Module}}{{.Module.Path}}{{end}}", - ]; - - if test { - args.push("-test"); - } - - // Scan all packages recursively by default so that dependencies imported - // only from subdirectories (internal/, pkg/, ...) are also inferred. - if packages.is_empty() { - args.push("./..."); - } else { - for package in packages { - args.push(package.as_str()); - } - } - - let result = exec(ExecCommandInput::pipe("go", args).cwd(dir.to_owned()))?; - - if result.exit_code != 0 { - return Ok(vec![]); - } - - Ok(result - .stdout - .lines() - .flat_map(|line| { - let line = line.trim(); - - if line.is_empty() { - None - } else { - Some(ModuleDependency { - module: Module { - module_path: line.into(), - // This is a hack for our use case! - version: if test { - "internal-test".into() - } else { - "".into() - }, - }, - indirect: false, - }) - } - }) - .collect()) -} - #[plugin_fn] pub fn extend_project_graph( Json(input): Json, ) -> FnResult> { - let mut output = ExtendProjectGraphOutput::default(); let config = parse_toolchain_config_schema::(input.toolchain_config)?; let env = get_host_environment()?; - let go_exists = command_exists(env, "go"); - - // First pass, gather all packages and their manifests - let mut packages = BTreeMap::default(); - - for (id, source) in input.project_sources { - let project_root = input.context.workspace_root.join(source); - let go_mod_path = project_root.join("go.mod"); - - let mut manifest = if go_mod_path.exists() { - output.input_files.push(go_mod_path.clone()); - parse_go_mod(fs::read_file(&go_mod_path)?)? - } else { - GoMod { - // This name isn't correct, but we need something! - module: id.to_string(), - ..Default::default() - } - }; - - if go_exists && is_go_project(&project_root) { - if config.infer_relationships { - manifest.require.extend(execute_go_list( - &project_root, - &config.infer_relationships_packages, - false, - )?); - } + let mut graph = GoPackageGraph::new( + input.context.workspace_root, + config, + command_exists(env, "go"), + ); - if config.infer_relationships_from_tests { - manifest.require.extend(execute_go_list( - &project_root, - &config.infer_relationships_packages, - true, - )?); - } - } + // First pass through, we figure out what projects we have and what their root import path is + graph.load_projects(input.project_sources)?; - packages.insert(manifest.module.clone(), (id, manifest)); - } + let mut output = ExtendProjectGraphOutput::default(); - // Second pass, extract packages and their relationships - for (id, manifest) in packages.values() { - let mut project_output = ExtendProjectOutput { - alias: if manifest.module.is_empty() || manifest.module == id.as_str() { - None - } else { - Some(manifest.module.clone()) - }, + // On the second pass, we work through all the projects and resolve their dependencies + for project in graph.projects() { + let project_output = ExtendProjectOutput { + alias: project.alias.clone(), + dependencies: graph.project_dependencies(project)?, ..Default::default() }; - for dep in &manifest.require { - let dep_module = &dep.module.module_path; - - if !dep.indirect - && packages - .get(dep_module) - .is_some_and(|(dep_id, _)| dep_id != id) - { - project_output.dependencies.push(ProjectDependency { - id: Id::raw(dep_module.clone()), - scope: if dep.module.version == "internal-test" { - DependencyScope::Development - } else { - DependencyScope::Production - }, - via: Some(format!("module {}", dep_module)), - }); - } - } - - if project_output.alias.is_some() - || !project_output.dependencies.is_empty() - || !project_output.tasks.is_empty() - { + if project_output.alias.is_some() || !project_output.dependencies.is_empty() { output .extended_projects - .insert(id.to_owned(), project_output); + .insert(project.id.to_owned(), project_output); } } + output.input_files = graph.into_input_files(); + Ok(Json(output)) } @@ -359,6 +240,29 @@ pub fn parse_manifest( Ok(Json(output)) } +fn is_bin_installed( + env: &HostEnvironment, + globals_dir: Option<&VirtualPath>, + module: &str, + version: &str, +) -> bool { + // Without a registry to inspect (like Cargo's `.crates.toml`), we can't + // verify which version an installed binary is, so entries pinned to a + // version, branch, or commit are always included. Their command only + // executes when the environment fingerprint changes, like a version bump + if version != "latest" { + return false; + } + + let Some(globals_dir) = globals_dir else { + return false; + }; + + globals_dir + .join(env.os.get_exe_name(get_bin_name(module))) + .exists() +} + #[plugin_fn] pub fn setup_environment( Json(input): Json, @@ -374,18 +278,23 @@ pub fn setup_environment( let mut bins_by_version = BTreeMap::default(); for bin in &config.bins { - let name = match bin { - BinEntry::String(inner) => inner, + let (name, force) = match bin { + BinEntry::String(inner) => (inner.as_str(), false), BinEntry::Object(cfg) => { if cfg.local && env.ci { continue; - } else { - cfg.bin.as_str() } + + (cfg.bin.as_str(), cfg.force) } }; let (module, version) = name.split_once('@').unwrap_or((name, "latest")); + + if !force && is_bin_installed(env, input.globals_dir.as_ref(), module, version) { + continue; + } + let base_module = get_base_module(module); bins_by_version @@ -442,3 +351,16 @@ fn get_base_module(module: &str) -> String { base } + +// The executable is named after the last segment of the module path, +// excluding a major version suffix: `github.com/foo/bar/v2` -> `bar` +fn get_bin_name(module: &str) -> &str { + let mut segments = module.rsplit('/'); + let last = segments.next().unwrap_or(module); + + if is_version_segment(last) { + return segments.next().unwrap_or(last); + } + + last +} diff --git a/toolchains/go/tests/__fixtures__/projects-single-module/apps/a/main.go b/toolchains/go/tests/__fixtures__/projects-single-module/apps/a/main.go new file mode 100644 index 00000000..5a3780f5 --- /dev/null +++ b/toolchains/go/tests/__fixtures__/projects-single-module/apps/a/main.go @@ -0,0 +1,7 @@ +package main + +import "example.com/org/libs/b" + +func main() { + b.B() +} diff --git a/toolchains/go/tests/__fixtures__/projects-single-module/apps/a/tool/tool.go b/toolchains/go/tests/__fixtures__/projects-single-module/apps/a/tool/tool.go new file mode 100644 index 00000000..6757a538 --- /dev/null +++ b/toolchains/go/tests/__fixtures__/projects-single-module/apps/a/tool/tool.go @@ -0,0 +1,3 @@ +package tool + +func Tool() {} diff --git a/toolchains/go/tests/__fixtures__/projects-single-module/go.mod b/toolchains/go/tests/__fixtures__/projects-single-module/go.mod new file mode 100644 index 00000000..9ced3f55 --- /dev/null +++ b/toolchains/go/tests/__fixtures__/projects-single-module/go.mod @@ -0,0 +1,3 @@ +module example.com/org + +go 1.24 diff --git a/toolchains/go/tests/__fixtures__/projects-single-module/libs/b/lib.go b/toolchains/go/tests/__fixtures__/projects-single-module/libs/b/lib.go new file mode 100644 index 00000000..9d2a7e60 --- /dev/null +++ b/toolchains/go/tests/__fixtures__/projects-single-module/libs/b/lib.go @@ -0,0 +1,3 @@ +package b + +func B() {} diff --git a/toolchains/go/tests/__fixtures__/projects-versioned/arbitrary/go.mod b/toolchains/go/tests/__fixtures__/projects-versioned/arbitrary/go.mod new file mode 100644 index 00000000..ce3f30a4 --- /dev/null +++ b/toolchains/go/tests/__fixtures__/projects-versioned/arbitrary/go.mod @@ -0,0 +1 @@ +module example.com/org/whatever diff --git a/toolchains/go/tests/__fixtures__/projects-versioned/consumer/go.mod b/toolchains/go/tests/__fixtures__/projects-versioned/consumer/go.mod new file mode 100644 index 00000000..75e6f7e1 --- /dev/null +++ b/toolchains/go/tests/__fixtures__/projects-versioned/consumer/go.mod @@ -0,0 +1,7 @@ +module example.com/org/consumer + +require ( + example.com/org/mod v1.0.0 + example.com/org/mod/v2 v2.0.0 + example.com/org/suffixed/v3 v3.0.0 +) diff --git a/toolchains/go/tests/__fixtures__/projects-versioned/mod/go.mod b/toolchains/go/tests/__fixtures__/projects-versioned/mod/go.mod new file mode 100644 index 00000000..705246cd --- /dev/null +++ b/toolchains/go/tests/__fixtures__/projects-versioned/mod/go.mod @@ -0,0 +1 @@ +module example.com/org/mod diff --git a/toolchains/go/tests/__fixtures__/projects-versioned/mod/v2/go.mod b/toolchains/go/tests/__fixtures__/projects-versioned/mod/v2/go.mod new file mode 100644 index 00000000..705246cd --- /dev/null +++ b/toolchains/go/tests/__fixtures__/projects-versioned/mod/v2/go.mod @@ -0,0 +1 @@ +module example.com/org/mod diff --git a/toolchains/go/tests/__fixtures__/projects-versioned/replacer/go.mod b/toolchains/go/tests/__fixtures__/projects-versioned/replacer/go.mod new file mode 100644 index 00000000..67e3ced0 --- /dev/null +++ b/toolchains/go/tests/__fixtures__/projects-versioned/replacer/go.mod @@ -0,0 +1,13 @@ +module example.com/org/replacer + +require ( + example.com/org/renamed v1.0.0 + example.com/org/mod v1.0.0 + example.com/org/outside v1.0.0 +) + +replace example.com/org/renamed => ../arbitrary + +replace example.com/org/mod => example.com/external/mod v1.0.0 + +replace example.com/org/outside => ../../outside diff --git a/toolchains/go/tests/__fixtures__/projects-versioned/suffixed/v3/go.mod b/toolchains/go/tests/__fixtures__/projects-versioned/suffixed/v3/go.mod new file mode 100644 index 00000000..63b6c07b --- /dev/null +++ b/toolchains/go/tests/__fixtures__/projects-versioned/suffixed/v3/go.mod @@ -0,0 +1 @@ +module example.com/org/suffixed/v3 diff --git a/toolchains/go/tests/__fixtures__/projects-workspace-versioned/consumer/go.mod b/toolchains/go/tests/__fixtures__/projects-workspace-versioned/consumer/go.mod new file mode 100644 index 00000000..625851a0 --- /dev/null +++ b/toolchains/go/tests/__fixtures__/projects-workspace-versioned/consumer/go.mod @@ -0,0 +1 @@ +module example.com/org/consumer diff --git a/toolchains/go/tests/__fixtures__/projects-workspace-versioned/consumer/lib.go b/toolchains/go/tests/__fixtures__/projects-workspace-versioned/consumer/lib.go new file mode 100644 index 00000000..5d4b3ee5 --- /dev/null +++ b/toolchains/go/tests/__fixtures__/projects-workspace-versioned/consumer/lib.go @@ -0,0 +1,11 @@ +package consumer + +import ( + modv1 "example.com/org/mod" + modv2 "example.com/org/mod/v2" +) + +func Consume() { + modv1.Old() + modv2.New() +} diff --git a/toolchains/go/tests/__fixtures__/projects-workspace-versioned/go.work b/toolchains/go/tests/__fixtures__/projects-workspace-versioned/go.work new file mode 100644 index 00000000..6d065e4e --- /dev/null +++ b/toolchains/go/tests/__fixtures__/projects-workspace-versioned/go.work @@ -0,0 +1,7 @@ +go 1.24 + +use ( + ./consumer + ./mod + ./mod/v2 +) diff --git a/toolchains/go/tests/__fixtures__/projects-workspace-versioned/mod/go.mod b/toolchains/go/tests/__fixtures__/projects-workspace-versioned/mod/go.mod new file mode 100644 index 00000000..705246cd --- /dev/null +++ b/toolchains/go/tests/__fixtures__/projects-workspace-versioned/mod/go.mod @@ -0,0 +1 @@ +module example.com/org/mod diff --git a/toolchains/go/tests/__fixtures__/projects-workspace-versioned/mod/lib.go b/toolchains/go/tests/__fixtures__/projects-workspace-versioned/mod/lib.go new file mode 100644 index 00000000..4c011136 --- /dev/null +++ b/toolchains/go/tests/__fixtures__/projects-workspace-versioned/mod/lib.go @@ -0,0 +1,3 @@ +package mod + +func Old() {} diff --git a/toolchains/go/tests/__fixtures__/projects-workspace-versioned/mod/v2/go.mod b/toolchains/go/tests/__fixtures__/projects-workspace-versioned/mod/v2/go.mod new file mode 100644 index 00000000..9d858339 --- /dev/null +++ b/toolchains/go/tests/__fixtures__/projects-workspace-versioned/mod/v2/go.mod @@ -0,0 +1 @@ +module example.com/org/mod/v2 diff --git a/toolchains/go/tests/__fixtures__/projects-workspace-versioned/mod/v2/lib.go b/toolchains/go/tests/__fixtures__/projects-workspace-versioned/mod/v2/lib.go new file mode 100644 index 00000000..683a5aaf --- /dev/null +++ b/toolchains/go/tests/__fixtures__/projects-workspace-versioned/mod/v2/lib.go @@ -0,0 +1,3 @@ +package mod + +func New() {} diff --git a/toolchains/go/tests/tier2_test.rs b/toolchains/go/tests/tier2_test.rs index c7a4a588..6b0569be 100644 --- a/toolchains/go/tests/tier2_test.rs +++ b/toolchains/go/tests/tier2_test.rs @@ -42,21 +42,12 @@ mod go_toolchain_tier2 { } ), ( + // `c` requires the sibling modules in its `go.mod` + // but has no source importing them; a require without + // a real import is not a relationship. Id::raw("c"), ExtendProjectOutput { alias: Some("example.com/org/c".into()), - dependencies: vec![ - ProjectDependency { - id: Id::raw("example.com/org/a"), - scope: DependencyScope::Production, - via: Some("module example.com/org/a".into()), - }, - ProjectDependency { - id: Id::raw("example.com/org/b"), - scope: DependencyScope::Production, - via: Some("module example.com/org/b".into()), - } - ], ..Default::default() } ), @@ -116,6 +107,116 @@ mod go_toolchain_tier2 { assert!(output.input_files.is_empty()); } + #[tokio::test(flavor = "multi_thread")] + async fn appends_major_version_folder_to_module() { + let sandbox = create_moon_sandbox("projects-versioned"); + let plugin = sandbox.create_toolchain("go").await; + + let mut input = ExtendProjectGraphInput::default(); + input + .project_sources + .insert(Id::raw("consumer"), "consumer".into()); + input.project_sources.insert(Id::raw("mod"), "mod".into()); + input + .project_sources + .insert(Id::raw("mod-v2"), "mod/v2".into()); + input + .project_sources + .insert(Id::raw("suffixed"), "suffixed/v3".into()); + + let output = plugin.extend_project_graph(input).await; + + assert_eq!( + output.extended_projects, + BTreeMap::from_iter([ + ( + // `consumer` requires the versioned modules in its + // `go.mod` but has no source importing them, so no + // relationships are created; the version-suffixed + // aliases below still resolve. + Id::raw("consumer"), + ExtendProjectOutput { + alias: Some("example.com/org/consumer".into()), + ..Default::default() + } + ), + ( + Id::raw("mod"), + ExtendProjectOutput { + alias: Some("example.com/org/mod".into()), + ..Default::default() + } + ), + ( + Id::raw("mod-v2"), + ExtendProjectOutput { + alias: Some("example.com/org/mod/v2".into()), + ..Default::default() + } + ), + ( + Id::raw("suffixed"), + ExtendProjectOutput { + alias: Some("example.com/org/suffixed/v3".into()), + ..Default::default() + } + ), + ]) + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn resolves_deps_through_replace_directives() { + let sandbox = create_moon_sandbox("projects-versioned"); + let plugin = sandbox.create_toolchain("go").await; + + let mut input = ExtendProjectGraphInput::default(); + input + .project_sources + .insert(Id::raw("arbitrary"), "arbitrary".into()); + input.project_sources.insert(Id::raw("mod"), "mod".into()); + input + .project_sources + .insert(Id::raw("replacer"), "replacer".into()); + + let output = plugin.extend_project_graph(input).await; + + assert_eq!( + output.extended_projects, + BTreeMap::from_iter([ + ( + Id::raw("arbitrary"), + ExtendProjectOutput { + alias: Some("example.com/org/whatever".into()), + ..Default::default() + } + ), + ( + Id::raw("mod"), + ExtendProjectOutput { + alias: Some("example.com/org/mod".into()), + ..Default::default() + } + ), + ( + Id::raw("replacer"), + ExtendProjectOutput { + alias: Some("example.com/org/replacer".into()), + // The `mod` require is replaced with an external + // module, and `outside` escapes the workspace, so + // neither creates a relationship + dependencies: vec![ProjectDependency { + id: Id::raw("arbitrary"), + scope: DependencyScope::Production, + via: Some("module example.com/org/renamed".into()), + }], + ..Default::default() + } + ), + ]) + ); + } + mod go_list { use super::*; @@ -157,14 +258,14 @@ mod go_toolchain_tier2 { alias: Some("example.com/org/c".into()), dependencies: vec![ ProjectDependency { - id: Id::raw("example.com/org/a"), + id: Id::raw("a"), scope: DependencyScope::Production, - via: Some("module example.com/org/a".into()), + via: Some("package example.com/org/a".into()), }, ProjectDependency { - id: Id::raw("example.com/org/b"), + id: Id::raw("b"), scope: DependencyScope::Production, - via: Some("module example.com/org/b".into()), + via: Some("package example.com/org/b".into()), } ], ..Default::default() @@ -183,6 +284,127 @@ mod go_toolchain_tier2 { ); } + #[tokio::test(flavor = "multi_thread")] + async fn distinguishes_major_versioned_modules() { + let sandbox = create_moon_sandbox("projects-workspace-versioned"); + let plugin = sandbox.create_toolchain("go").await; + + let mut input = ExtendProjectGraphInput::default(); + input + .project_sources + .insert(Id::raw("consumer"), "consumer".into()); + input.project_sources.insert(Id::raw("mod"), "mod".into()); + input + .project_sources + .insert(Id::raw("mod-v2"), "mod/v2".into()); + input.toolchain_config = json!({ + "inferRelationships": true + }); + + let output = plugin.extend_project_graph(input).await; + + // `example.com/org/mod` prefixes `example.com/org/mod/v2`, so + // the v2 import must resolve to the v2 project rather than + // collapsing into the v1 module. + assert_eq!( + output.extended_projects.get(&Id::raw("consumer")), + Some(&ExtendProjectOutput { + alias: Some("example.com/org/consumer".into()), + dependencies: vec![ + ProjectDependency { + id: Id::raw("mod"), + scope: DependencyScope::Production, + via: Some("package example.com/org/mod".into()), + }, + ProjectDependency { + id: Id::raw("mod-v2"), + scope: DependencyScope::Production, + via: Some("package example.com/org/mod/v2".into()), + }, + ], + ..Default::default() + }) + ); + + assert_eq!( + output.extended_projects.get(&Id::raw("mod-v2")), + Some(&ExtendProjectOutput { + alias: Some("example.com/org/mod/v2".into()), + ..Default::default() + }) + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn infers_relations_within_a_single_module() { + let sandbox = create_moon_sandbox("projects-single-module"); + let plugin = sandbox.create_toolchain("go").await; + + let mut input = ExtendProjectGraphInput::default(); + input.project_sources.insert(Id::raw("a"), "apps/a".into()); + input.project_sources.insert(Id::raw("b"), "libs/b".into()); + input.toolchain_config = json!({ + "inferRelationships": true + }); + + let output = plugin.extend_project_graph(input).await; + + // Both projects derive their import path from the root go.mod, + // so only "a" has anything to output. + assert_eq!( + output.extended_projects, + BTreeMap::from_iter([( + Id::raw("a"), + ExtendProjectOutput { + dependencies: vec![ProjectDependency { + id: Id::raw("b"), + scope: DependencyScope::Production, + via: Some("package example.com/org/libs/b".into()), + }], + ..Default::default() + } + )]) + ); + + assert_eq!(output.input_files, [VirtualPath::new("/workspace/go.mod")]); + } + + #[tokio::test(flavor = "multi_thread")] + async fn doesnt_infer_edges_to_nested_projects_it_never_imports() { + let sandbox = create_moon_sandbox("projects-single-module"); + let plugin = sandbox.create_toolchain("go").await; + + let mut input = ExtendProjectGraphInput::default(); + input.project_sources.insert(Id::raw("a"), "apps/a".into()); + input.project_sources.insert(Id::raw("b"), "libs/b".into()); + input + .project_sources + .insert(Id::raw("tool"), "apps/a/tool".into()); + input.toolchain_config = json!({ + "inferRelationships": true + }); + + let output = plugin.extend_project_graph(input).await; + + // `go list -deps ./...` run from `a` enumerates the nested + // `tool` project's package as a root even though nothing + // imports it; ownership must not become a dependency edge. + assert_eq!( + output.extended_projects, + BTreeMap::from_iter([( + Id::raw("a"), + ExtendProjectOutput { + dependencies: vec![ProjectDependency { + id: Id::raw("b"), + scope: DependencyScope::Production, + via: Some("package example.com/org/libs/b".into()), + }], + ..Default::default() + } + )]) + ); + } + #[tokio::test(flavor = "multi_thread")] async fn doesnt_infer_relations_if_config_disabled() { let sandbox = create_moon_sandbox("projects-workspace"); @@ -256,9 +478,9 @@ mod go_toolchain_tier2 { Some(&ExtendProjectOutput { alias: Some("example.com/org/d".into()), dependencies: vec![ProjectDependency { - id: Id::raw("example.com/org/a"), + id: Id::raw("a"), scope: DependencyScope::Production, - via: Some("module example.com/org/a".into()), + via: Some("package example.com/org/a".into()), }], ..Default::default() }) @@ -279,18 +501,17 @@ mod go_toolchain_tier2 { let output = plugin.extend_project_graph(input).await; - // `e` imports `example.com/org/a` only through the `a/pkg` - // subpackage, so the dependency is only inferred when - // `go list -deps` emits the owning module path via - // `-f {{if .Module}}{{.Module.Path}}{{end}}`. + // `e` never imports `example.com/org/a` itself, only the + // `a/pkg` subpackage, so the dependency relies on prefix + // matching rather than an exact import path match. assert_eq!( output.extended_projects.get(&Id::raw("e")), Some(&ExtendProjectOutput { alias: Some("example.com/org/e".into()), dependencies: vec![ProjectDependency { - id: Id::raw("example.com/org/a"), + id: Id::raw("a"), scope: DependencyScope::Production, - via: Some("module example.com/org/a".into()), + via: Some("package example.com/org/a/pkg".into()), }], ..Default::default() }) @@ -937,5 +1158,149 @@ mod go_toolchain_tier2 { assert!(output.commands.is_empty()); } + + // https://github.com/moonrepo/plugins/issues/137 + + #[tokio::test(flavor = "multi_thread")] + async fn only_installs_missing_bins() { + let sandbox = create_empty_moon_sandbox(); + sandbox.create_file(".go/bin/gopls", ""); + sandbox.create_file(".go/bin/gopls.exe", ""); + + let plugin = sandbox.create_toolchain("go").await; + + let output = plugin + .setup_environment(SetupEnvironmentInput { + root: VirtualPath::new(sandbox.path()), + globals_dir: Some(VirtualPath::new(sandbox.path().join(".go/bin"))), + toolchain_config: json!({ + "bins": ["golang.org/x/tools/gopls", "github.com/revel/cmd/revel"] + }), + ..Default::default() + }) + .await; + + assert_eq!( + output.commands, + [ExecCommand::new( + ExecCommandInput::new("go", ["install", "-v", "github.com/revel/cmd/revel"],) + .cwd(plugin.plugin.to_virtual_path(sandbox.path())) + ) + .cache(CacheStrategy::Memory) + .label("go-bins-github.com/revel/cmd@latest")] + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn adds_no_commands_if_all_bins_installed() { + let sandbox = create_empty_moon_sandbox(); + sandbox.create_file(".go/bin/gopls", ""); + sandbox.create_file(".go/bin/gopls.exe", ""); + + let plugin = sandbox.create_toolchain("go").await; + + let output = plugin + .setup_environment(SetupEnvironmentInput { + root: VirtualPath::new(sandbox.path()), + globals_dir: Some(VirtualPath::new(sandbox.path().join(".go/bin"))), + toolchain_config: json!({ + "bins": ["golang.org/x/tools/gopls"] + }), + ..Default::default() + }) + .await; + + assert!(output.commands.is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn checks_exe_name_excluding_major_version_suffix() { + let sandbox = create_empty_moon_sandbox(); + sandbox.create_file(".go/bin/revel", ""); + sandbox.create_file(".go/bin/revel.exe", ""); + + let plugin = sandbox.create_toolchain("go").await; + + let output = plugin + .setup_environment(SetupEnvironmentInput { + root: VirtualPath::new(sandbox.path()), + globals_dir: Some(VirtualPath::new(sandbox.path().join(".go/bin"))), + toolchain_config: json!({ + "bins": ["github.com/revel/cmd/revel/v2"] + }), + ..Default::default() + }) + .await; + + assert!(output.commands.is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn always_installs_versioned_bins() { + let sandbox = create_empty_moon_sandbox(); + sandbox.create_file(".go/bin/gopls", ""); + sandbox.create_file(".go/bin/gopls.exe", ""); + + let plugin = sandbox.create_toolchain("go").await; + + let output = plugin + .setup_environment(SetupEnvironmentInput { + root: VirtualPath::new(sandbox.path()), + globals_dir: Some(VirtualPath::new(sandbox.path().join(".go/bin"))), + toolchain_config: json!({ + "bins": ["golang.org/x/tools/gopls@v0.16.0"] + }), + ..Default::default() + }) + .await; + + assert_eq!( + output.commands, + [ExecCommand::new( + ExecCommandInput::new( + "go", + ["install", "-v", "golang.org/x/tools/gopls@v0.16.0"], + ) + .cwd(plugin.plugin.to_virtual_path(sandbox.path())) + ) + .cache(CacheStrategy::Memory) + .label("go-bins-golang.org/x/tools@v0.16.0")] + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn always_installs_forced_bins() { + let sandbox = create_empty_moon_sandbox(); + sandbox.create_file(".go/bin/gopls", ""); + sandbox.create_file(".go/bin/gopls.exe", ""); + + let plugin = sandbox.create_toolchain("go").await; + + let output = plugin + .setup_environment(SetupEnvironmentInput { + root: VirtualPath::new(sandbox.path()), + globals_dir: Some(VirtualPath::new(sandbox.path().join(".go/bin"))), + toolchain_config: json!({ + "bins": [ + { + "bin": "golang.org/x/tools/gopls", + "force": true + } + ] + }), + ..Default::default() + }) + .await; + + assert_eq!( + output.commands, + [ExecCommand::new( + ExecCommandInput::new("go", ["install", "-v", "golang.org/x/tools/gopls"],) + .cwd(plugin.plugin.to_virtual_path(sandbox.path())) + ) + .cache(CacheStrategy::Memory) + .label("go-bins-golang.org/x/tools@latest")] + ); + } } } diff --git a/toolchains/javascript/CHANGELOG.md b/toolchains/javascript/CHANGELOG.md index e1728bdc..a14e398e 100644 --- a/toolchains/javascript/CHANGELOG.md +++ b/toolchains/javascript/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 1.3.0 + +#### 🚀 Updates + +- Added unstable support for [Nub](https://nubjs.com/) as a package manager: + - Natively uses `nub.lock` (pnpm lockfile format), but will locate dependency + roots using other package manager lockfiles that nub can operate on. + - Reads workspace members and catalogs from `pnpm-workspace.yaml` when + present, otherwise from `package.json`. + - Does not require the Node.js toolchain, as nub is a standalone binary. + +#### 🐞 Fixes + +- Fixed `bun.lock` parsing failing on Git/GitHub dependencies that include both + package metadata (`dependencies`, `bin`, etc) and an integrity hash. + ## 1.2.2 #### 🚀 Updates diff --git a/toolchains/javascript/Cargo.toml b/toolchains/javascript/Cargo.toml index 3d269354..018dc641 100644 --- a/toolchains/javascript/Cargo.toml +++ b/toolchains/javascript/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "javascript_toolchain" -version = "1.2.2" +version = "1.3.0" edition = "2024" description = "JavaScript toolchain WASM plugin for moon." authors = ["Miles Johnson"] diff --git a/toolchains/javascript/src/config.rs b/toolchains/javascript/src/config.rs index 6a42cf08..d389f6bb 100644 --- a/toolchains/javascript/src/config.rs +++ b/toolchains/javascript/src/config.rs @@ -12,6 +12,7 @@ derive_enum!( Deno, #[default] Npm, + Nub, Pnpm, Yarn, } @@ -20,14 +21,22 @@ derive_enum!( impl JavaScriptPackageManager { pub fn get_runtime_toolchain(&self) -> Id { match self { - JavaScriptPackageManager::Bun => Id::raw("bun"), - JavaScriptPackageManager::Deno => Id::raw("deno"), + Self::Bun => Id::raw("bun"), + Self::Deno => Id::raw("deno"), + Self::Nub => Id::raw("nub"), _ => Id::raw("node"), } } + /// Installs dependencies for the Node.js ecosystem. pub fn is_for_node(&self) -> bool { - matches!(self, Self::Npm | Self::Pnpm | Self::Yarn) + matches!(self, Self::Npm | Self::Nub | Self::Pnpm | Self::Yarn) + } + + /// Runs as a standalone binary and does not require the + /// Node.js toolchain to operate. + pub fn is_standalone(&self) -> bool { + matches!(self, Self::Bun | Self::Deno | Self::Nub) } } diff --git a/toolchains/javascript/src/infer_tasks.rs b/toolchains/javascript/src/infer_tasks.rs index 605c5ce2..c706a237 100644 --- a/toolchains/javascript/src/infer_tasks.rs +++ b/toolchains/javascript/src/infer_tasks.rs @@ -139,10 +139,7 @@ impl<'a> TasksInferrer<'a> { } // toolchains - if matches!( - package_manager, - JavaScriptPackageManager::Bun | JavaScriptPackageManager::Deno - ) { + if package_manager.is_standalone() { config.toolchains = Some(OneOrMany::Many(vec![ Id::raw("javascript"), package_manager.get_runtime_toolchain(), diff --git a/toolchains/javascript/src/lockfiles/bun.rs b/toolchains/javascript/src/lockfiles/bun.rs index c8c7a37c..8e359c13 100644 --- a/toolchains/javascript/src/lockfiles/bun.rs +++ b/toolchains/javascript/src/lockfiles/bun.rs @@ -17,25 +17,46 @@ pub struct BunLockPackageJson { pub optional_dependencies: BTreeMap, } +// Entry shapes are defined by bun's lockfile serializer: +// https://github.com/oven-sh/bun/blob/main/src/install/lockfile/bun.lock.rs +// npm -> [ "name@version", registry, INFO, integrity ] +// git -> [ "name@git+repo", INFO, bun tag, integrity? ] +// github -> [ "name@github:user/repo", INFO, bun tag, integrity? ] +// tarball -> [ "name@url", INFO, integrity? ] +// symlink -> [ "name@link:path", INFO ] +// folder -> [ "name@file:path", INFO ] +// root -> [ "name@root:", INFO ] +// workspace -> [ "name@workspace:path" ] #[derive(Debug, Deserialize)] #[serde(untagged)] pub enum BunLockPackage { + // npm Dependency1( String, // identifier - String, // ??? + String, // registry JsonValue, // object String, // sha ), + // git/github with integrity Dependency2( String, // identifier JsonValue, // object + String, // bun tag String, // sha ), + // git/github without integrity, tarball Dependency3( String, // identifier JsonValue, // object + String, // bun tag or sha + ), + + // symlink, folder, root + Dependency4( + String, // identifier + JsonValue, // object ), // Must be last! @@ -79,21 +100,28 @@ pub fn parse_bun_lock(path: &VirtualPath, output: &mut ParseLockOutput) -> AnyRe continue; } - BunLockPackage::Dependency1(id, _unknown, _data, integrity) => { + BunLockPackage::Dependency1(id, _registry, _data, integrity) => { + let Some((name, version)) = parse_name_and_version(id, "") else { + continue; + }; + + (name, version, Some(integrity)) + } + BunLockPackage::Dependency2(id, _data, _tag, integrity) => { let Some((name, version)) = parse_name_and_version(id, "") else { continue; }; (name, version, Some(integrity)) } - BunLockPackage::Dependency2(id, _data, integrity) => { + BunLockPackage::Dependency3(id, _data, integrity) => { let Some((name, version)) = parse_name_and_version(id, "") else { continue; }; (name, version, Some(integrity)) } - BunLockPackage::Dependency3(id, _data) => { + BunLockPackage::Dependency4(id, _data) => { let Some((name, version)) = parse_name_and_version(id, "") else { continue; }; @@ -121,3 +149,58 @@ pub fn parse_bun_lockb(path: &VirtualPath, output: &mut ParseLockOutput) -> AnyR parse_yarn_lock_content(content.stdout.trim(), output) } + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(content: &str) -> BunLockPackage { + json::parse(content).unwrap() + } + + #[test] + fn parses_npm_entry() { + assert!(matches!( + parse(r#"["csstype@3.1.3", "", {}, "sha512-M1uQ=="]"#), + BunLockPackage::Dependency1(..) + )); + } + + // https://github.com/moonrepo/plugins/issues/174 + #[test] + fn parses_github_entry_with_integrity() { + assert!(matches!( + parse( + r#"["@portkey-ai/gateway@github:Portkey-AI/gateway#ca77129", { "dependencies": { "async-retry": "^1.3.3" }, "bin": "build/start-server.js" }, "Portkey-AI-gateway-ca77129", "sha512-71lq=="]"# + ), + BunLockPackage::Dependency2(..) + )); + } + + // https://github.com/moonrepo/moon/issues/2049 + #[test] + fn parses_github_entry_without_integrity() { + assert!(matches!( + parse( + r#"["uWebSockets.js@github:uNetworking/uWebSockets.js#6609a88", {}, "uNetworking-uWebSockets.js-6609a88"]"# + ), + BunLockPackage::Dependency3(..) + )); + } + + #[test] + fn parses_symlink_entry() { + assert!(matches!( + parse(r#"["local-lib@link:packages/local-lib", {}]"#), + BunLockPackage::Dependency4(..) + )); + } + + #[test] + fn parses_workspace_entry() { + assert!(matches!( + parse(r#"["a@workspace:packages/a"]"#), + BunLockPackage::Workspace(..) + )); + } +} diff --git a/toolchains/javascript/src/tier1.rs b/toolchains/javascript/src/tier1.rs index 1f4d26ab..59a9ca4c 100644 --- a/toolchains/javascript/src/tier1.rs +++ b/toolchains/javascript/src/tier1.rs @@ -42,6 +42,8 @@ pub fn register_toolchain( "deno.jsonc".into(), // npm ".npmrc".into(), + // nub + "nub.jsonc".into(), // pnpm "pnpm-workspace.yaml".into(), ".pnpmfile.*".into(), @@ -60,6 +62,8 @@ pub fn register_toolchain( // npm "package-lock.json".into(), "npm-shrinkwrap.json".into(), + // nub + "nub.lock".into(), // pnpm "pnpm-lock.yaml".into(), // yarn @@ -78,9 +82,14 @@ pub fn register_toolchain( // npm "npm".into(), "npx".into(), + // nub + "nub".into(), + "nubx".into(), // pnpm "pnpm".into(), "pnpx".into(), + "pn".into(), + "pnx".into(), // yarn "yarn".into(), "yarnpkg".into() @@ -114,6 +123,7 @@ pub fn initialize_toolchain( JsonValue::String("bun".into()), JsonValue::String("deno".into()), JsonValue::String("npm".into()), + JsonValue::String("nub".into()), JsonValue::String("pnpm".into()), JsonValue::String("yarn".into()), ], @@ -157,6 +167,8 @@ fn detect_package_manager(root: &VirtualPath) -> AnyResult FnResult> { let config = parse_toolchain_config_schema::(input.toolchain_config)?; - let mut output = DefineRequirementsOutput::default(); + let mut output = DefineRequirementsOutput { + for_setup_toolchain: true, + ..Default::default() + }; if let Some(package_manager) = config.package_manager { - if package_manager.is_for_node() { + if !package_manager.is_standalone() { output.requires.push("node".into()); } @@ -216,6 +219,18 @@ pub fn define_requirements( Ok(Json(output)) } +// Nub natively uses `nub.lock`, but respects the lockfiles of other +// package managers (for both resolution and layout), so treat them +// all as valid dependency roots for migration +const NUB_LOCK_NAMES: &[&str] = &[ + "nub.lock", + "package-lock.json", + "npm-shrinkwrap.json", + "pnpm-lock.yaml", + "yarn.lock", + "bun.lock", +]; + fn get_var_key(prefix: &str, root: &VirtualPath) -> String { format!("{prefix}:{}", root.to_string().trim_end_matches('/')) } @@ -253,7 +268,9 @@ fn extract_workspace_members_and_catalogs( catalogs = deno.extract_catalogs(); members = deno.workspace.map(|ws| ws.get_members().to_vec()); } - JavaScriptPackageManager::Pnpm => { + // Nub reads `pnpm-workspace.yaml` when operating on a pnpm + // workspace, otherwise `package.json` (handled below) + JavaScriptPackageManager::Nub | JavaScriptPackageManager::Pnpm => { let workspace_file = root.join("pnpm-workspace.yaml"); if workspace_file.exists() { @@ -322,7 +339,9 @@ pub fn locate_dependencies_root( let workspace_manifest_names = match package_manager { JavaScriptPackageManager::Deno => vec!["deno.json", "deno.jsonc", "package.json"], - JavaScriptPackageManager::Pnpm => vec!["pnpm-workspace.yaml", "package.json"], + JavaScriptPackageManager::Nub | JavaScriptPackageManager::Pnpm => { + vec!["pnpm-workspace.yaml", "package.json"] + } _ => vec!["package.json"], }; @@ -345,6 +364,7 @@ pub fn locate_dependencies_root( } } JavaScriptPackageManager::Npm => vec!["package-lock.json", "npm-shrinkwrap.json"], + JavaScriptPackageManager::Nub => NUB_LOCK_NAMES.to_vec(), JavaScriptPackageManager::Pnpm => vec!["pnpm-lock.yaml"], JavaScriptPackageManager::Yarn => vec!["yarn.lock"], }; @@ -469,6 +489,39 @@ pub fn install_dependencies( cmd } + JavaScriptPackageManager::Nub => { + // `nub ci` installs strictly from the lockfile, but has no + // `--prod` flag, so fall back to `nub install` for production + // installs, which is frozen by default in CI anyways + let use_ci = env.ci + && !input.production + && NUB_LOCK_NAMES + .iter() + .any(|name| input.root.join(name).exists()); + + let mut cmd = if use_ci { + ExecCommandInput::new("nub", ["ci"]) + } else { + ExecCommandInput::new("nub", ["install"]) + }; + + if input.production { + cmd.args.push("--prod".into()); + } + + for package_name in input.packages { + cmd.args.push(if input.production { + "--filter-prod".into() + } else { + "--filter".into() + }); + + // https://nubjs.com/docs/install#nub-install + cmd.args.push(format!("{package_name}...")); + } + + cmd + } JavaScriptPackageManager::Pnpm => { let mut cmd = ExecCommandInput::new("pnpm", ["install"]); @@ -530,6 +583,13 @@ pub fn install_dependencies( .into(), ); } + JavaScriptPackageManager::Nub => { + output.dedupe_command = Some( + ExecCommandInput::new("nub", ["dedupe"]) + .cwd(input.root) + .into(), + ); + } JavaScriptPackageManager::Pnpm => { output.dedupe_command = Some(if package_manager_config.version_satisfies("<7.26.0") { @@ -590,7 +650,10 @@ pub fn parse_lock(Json(input): Json) -> FnResult { parse_package_lock_json(&input.path, &mut output)? } - Some("pnpm-lock.yaml") => parse_pnpm_lock_yaml(&input.path, &mut output)?, + // `nub.lock` uses the pnpm lockfile format + Some("nub.lock") | Some("pnpm-lock.yaml") => { + parse_pnpm_lock_yaml(&input.path, &mut output)? + } Some("yarn.lock") => parse_yarn_lock(&input.path, &mut output)?, _ => {} }; diff --git a/toolchains/javascript/tests/__fixtures__/lockfiles/bun/bun.lock b/toolchains/javascript/tests/__fixtures__/lockfiles/bun/bun.lock index f77a9680..2eecfb41 100644 --- a/toolchains/javascript/tests/__fixtures__/lockfiles/bun/bun.lock +++ b/toolchains/javascript/tests/__fixtures__/lockfiles/bun/bun.lock @@ -2,6 +2,10 @@ "lockfileVersion": 1, "workspaces": { "": { + "dependencies": { + "@portkey-ai/gateway": "git+https://github.com/Portkey-AI/gateway.git#v1.15.2", + "uWebSockets.js": "github:uNetworking/uWebSockets.js#6609a88", + }, "devDependencies": { "typescript": "^5.9.0", }, @@ -31,6 +35,8 @@ }, }, "packages": { + "@portkey-ai/gateway": ["@portkey-ai/gateway@github:Portkey-AI/gateway#ca77129", { "dependencies": { "react": "^19.1.0" }, "bin": "build/start-server.js" }, "Portkey-AI-gateway-ca77129", "sha512-71lqRjKGGxMSs4l6DdrzXHkGvitFdCsWxLbxYVfbYDTfhqOL0dJbmqjyrKObjSTbQqCXTh2XZQeI2Un0FN7pig=="], + "a": ["a@workspace:a"], "b": ["b@workspace:b"], @@ -48,5 +54,7 @@ "solid-js": ["solid-js@1.9.9", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-A0ZBPJQldAeGCTW0YRYJmt7RCeh5rbFfPZ2aOttgYnctHE7HgKeHCBB/PVc2P7eOfmNXqMFFFoYYdm3S4dcbkA=="], "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], + + "uWebSockets.js": ["uWebSockets.js@github:uNetworking/uWebSockets.js#6609a88", {}, "uNetworking-uWebSockets.js-6609a88"], } } diff --git a/toolchains/javascript/tests/__fixtures__/lockfiles/nub/nub.lock b/toolchains/javascript/tests/__fixtures__/lockfiles/nub/nub.lock new file mode 100644 index 00000000..52fd1b9e --- /dev/null +++ b/toolchains/javascript/tests/__fixtures__/lockfiles/nub/nub.lock @@ -0,0 +1,84 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + typescript: + specifier: ^5.9.0 + version: 5.9.2 + + a: + dependencies: + react: + specifier: ^19.1.0 + version: 19.1.1 + + b: + dependencies: + react: + specifier: ^19.1.0 + version: 19.1.1 + solid-js: + specifier: ~1.9.9 + version: 1.9.9 + + c: + dependencies: + a: + specifier: file:../a + version: link:../a + solid-js: + specifier: ~1.9.9 + version: 1.9.9 + +packages: + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + react@19.1.1: + resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==} + engines: {node: '>=0.10.0'} + + seroval-plugins@1.3.2: + resolution: {integrity: sha512-0QvCV2lM3aj/U3YozDiVwx9zpH0q8A60CTWIv4Jszj/givcudPb48B+rkU5D51NJ0pTpweGMttHjboPa9/zoIQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.3.2: + resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==} + engines: {node: '>=10'} + + solid-js@1.9.9: + resolution: {integrity: sha512-A0ZBPJQldAeGCTW0YRYJmt7RCeh5rbFfPZ2aOttgYnctHE7HgKeHCBB/PVc2P7eOfmNXqMFFFoYYdm3S4dcbkA==} + + typescript@5.9.2: + resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} + engines: {node: '>=14.17'} + hasBin: true + +snapshots: + + csstype@3.1.3: {} + + react@19.1.1: {} + + seroval-plugins@1.3.2(seroval@1.3.2): + dependencies: + seroval: 1.3.2 + + seroval@1.3.2: {} + + solid-js@1.9.9: + dependencies: + csstype: 3.1.3 + seroval: 1.3.2 + seroval-plugins: 1.3.2(seroval@1.3.2) + + typescript@5.9.2: {} diff --git a/toolchains/javascript/tests/tier1_test.rs b/toolchains/javascript/tests/tier1_test.rs index b18e7e4d..5117ab9c 100644 --- a/toolchains/javascript/tests/tier1_test.rs +++ b/toolchains/javascript/tests/tier1_test.rs @@ -111,6 +111,40 @@ mod javascript_toolchain_tier1 { ); } + #[tokio::test(flavor = "multi_thread")] + async fn detects_nub_via_lockfile() { + let sandbox = create_empty_moon_sandbox(); + sandbox.create_file("nub.lock", ""); + + let plugin = sandbox.create_toolchain("javascript").await; + + let output = plugin + .initialize_toolchain(InitializeToolchainInput::default()) + .await; + + assert_eq!( + output.default_settings.get("packageManager").unwrap(), + &JsonValue::String("nub".into()) + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn detects_nub_via_package_json() { + let sandbox = create_empty_moon_sandbox(); + sandbox.create_file("package.json", r#"{ "packageManager": "nub@0.7.0" }"#); + + let plugin = sandbox.create_toolchain("javascript").await; + + let output = plugin + .initialize_toolchain(InitializeToolchainInput::default()) + .await; + + assert_eq!( + output.default_settings.get("packageManager").unwrap(), + &JsonValue::String("nub".into()) + ); + } + #[tokio::test(flavor = "multi_thread")] async fn detects_pnpm_via_lockfile() { let sandbox = create_empty_moon_sandbox(); diff --git a/toolchains/javascript/tests/tier2_env_test.rs b/toolchains/javascript/tests/tier2_env_test.rs index e654a360..4032a490 100644 --- a/toolchains/javascript/tests/tier2_env_test.rs +++ b/toolchains/javascript/tests/tier2_env_test.rs @@ -189,6 +189,38 @@ mod javascript_toolchain_tier2 { ); } + #[tokio::test(flavor = "multi_thread")] + async fn sets_field_for_nub() { + let mut sandbox = create_moon_sandbox("files"); + + sandbox + .host_funcs + .mock_load_toolchain_config(|_, _| json!({ "version": "1.2.3" })); + + let plugin = sandbox.create_toolchain("javascript").await; + + let output = plugin + .setup_environment(SetupEnvironmentInput { + root: VirtualPath::new(sandbox.path()), + toolchain_config: json!({ + "syncPackageManagerField": true, + "packageManager": "nub" + }), + ..Default::default() + }) + .await; + + assert_eq!( + output.changed_files, + [VirtualPath::new("/workspace/package.json")] + ); + assert!( + fs::read_to_string(sandbox.path().join("package.json")) + .unwrap() + .contains(r#""packageManager": "nub@1.2.3""#) + ); + } + #[tokio::test(flavor = "multi_thread")] async fn sets_field() { let mut sandbox = create_moon_sandbox("files"); diff --git a/toolchains/javascript/tests/tier2_test.rs b/toolchains/javascript/tests/tier2_test.rs index 5bcac4ef..d857b7b0 100644 --- a/toolchains/javascript/tests/tier2_test.rs +++ b/toolchains/javascript/tests/tier2_test.rs @@ -596,6 +596,48 @@ mod javascript_toolchain_tier2 { assert_eq!(output.root.unwrap(), VirtualPath::new("/workspace/package")); } + #[tokio::test(flavor = "multi_thread")] + async fn finds_package_with_nub_lock() { + let sandbox = create_moon_sandbox("locate"); + sandbox.create_file("package/nub.lock", ""); + + let plugin = sandbox.create_toolchain("javascript").await; + + let output = plugin + .locate_dependencies_root(LocateDependenciesRootInput { + starting_dir: VirtualPath::new(sandbox.path().join("package/nested")), + toolchain_config: json!({ + "packageManager": "nub" + }), + ..Default::default() + }) + .await; + + assert!(output.members.is_none()); + assert_eq!(output.root.unwrap(), VirtualPath::new("/workspace/package")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn finds_package_with_other_pm_lock_when_using_nub() { + let sandbox = create_moon_sandbox("locate"); + sandbox.create_file("package/pnpm-lock.yaml", ""); + + let plugin = sandbox.create_toolchain("javascript").await; + + let output = plugin + .locate_dependencies_root(LocateDependenciesRootInput { + starting_dir: VirtualPath::new(sandbox.path().join("package/nested")), + toolchain_config: json!({ + "packageManager": "nub" + }), + ..Default::default() + }) + .await; + + assert!(output.members.is_none()); + assert_eq!(output.root.unwrap(), VirtualPath::new("/workspace/package")); + } + #[tokio::test(flavor = "multi_thread")] async fn finds_package_with_pnpm_lock() { let sandbox = create_moon_sandbox("locate"); @@ -800,6 +842,58 @@ mod javascript_toolchain_tier2 { ); } + #[tokio::test(flavor = "multi_thread")] + async fn finds_workspace_with_nub_lock() { + let sandbox = create_moon_sandbox("locate"); + sandbox.create_file("workspace/nub.lock", ""); + + let plugin = sandbox.create_toolchain("javascript").await; + + let output = plugin + .locate_dependencies_root(LocateDependenciesRootInput { + starting_dir: VirtualPath::new( + sandbox.path().join("workspace/packages/a/nested"), + ), + toolchain_config: json!({ + "packageManager": "nub" + }), + ..Default::default() + }) + .await; + + assert_eq!(output.members.unwrap(), ["packages/*"]); + assert_eq!( + output.root.unwrap(), + VirtualPath::new("/workspace/workspace") + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn finds_workspace_with_pnpm_workspace_when_using_nub() { + let sandbox = create_moon_sandbox("locate"); + sandbox.create_file("workspace/pnpm-workspace.yaml", "packages: ['apps/*']"); + + let plugin = sandbox.create_toolchain("javascript").await; + + let output = plugin + .locate_dependencies_root(LocateDependenciesRootInput { + starting_dir: VirtualPath::new( + sandbox.path().join("workspace/packages/a/nested"), + ), + toolchain_config: json!({ + "packageManager": "nub" + }), + ..Default::default() + }) + .await; + + assert_eq!(output.members.unwrap(), ["apps/*"]); + assert_eq!( + output.root.unwrap(), + VirtualPath::new("/workspace/workspace") + ); + } + #[tokio::test(flavor = "multi_thread")] async fn finds_workspace_with_pnpm() { let sandbox = create_moon_sandbox("locate"); @@ -1390,6 +1484,216 @@ mod javascript_toolchain_tier2 { } } + mod nub { + use super::*; + + #[tokio::test(flavor = "multi_thread")] + async fn default_commands() { + let sandbox = create_empty_moon_sandbox(); + let plugin = sandbox.create_toolchain("javascript").await; + + let output = plugin + .install_dependencies(InstallDependenciesInput { + root: VirtualPath::new(sandbox.path()), + toolchain_config: json!({ + "packageManager": "nub", + "dedupeOnLockfileChange": true + }), + ..Default::default() + }) + .await; + + assert_eq!( + output.install_command.unwrap(), + ExecCommand::new( + ExecCommandInput::new("nub", ["install"]) + .cwd(plugin.plugin.to_virtual_path(sandbox.path())) + ) + ); + + assert_eq!( + output.dedupe_command.unwrap(), + ExecCommand::new( + ExecCommandInput::new("nub", ["dedupe"]) + .cwd(plugin.plugin.to_virtual_path(sandbox.path())) + ) + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn focused_commands() { + let sandbox = create_empty_moon_sandbox(); + let plugin = sandbox.create_toolchain("javascript").await; + + let output = plugin + .install_dependencies(InstallDependenciesInput { + root: VirtualPath::new(sandbox.path()), + toolchain_config: json!({ + "packageManager": "nub", + "dedupeOnLockfileChange": true + }), + packages: vec!["foo".into(), "@scope/bar".into()], + production: true, + ..Default::default() + }) + .await; + + assert_eq!( + output.install_command.unwrap(), + ExecCommand::new( + ExecCommandInput::new( + "nub", + [ + "install", + "--prod", + "--filter-prod", + "foo...", + "--filter-prod", + "@scope/bar..." + ] + ) + .cwd(plugin.plugin.to_virtual_path(sandbox.path())) + ) + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn inherits_args_from_nub_toolchain() { + let mut sandbox = create_empty_moon_sandbox(); + + sandbox + .host_funcs + .mock_load_toolchain_config(|_, _| json!({ "installArgs": ["-a", "b", "--c"]})); + + let plugin = sandbox.create_toolchain("javascript").await; + + let output = plugin + .install_dependencies(InstallDependenciesInput { + root: VirtualPath::new(sandbox.path()), + toolchain_config: json!({ + "packageManager": "nub", + "dedupeOnLockfileChange": true + }), + ..Default::default() + }) + .await; + + assert_eq!( + output.install_command.unwrap(), + ExecCommand::new( + ExecCommandInput::new("nub", ["install", "-a", "b", "--c"]) + .cwd(plugin.plugin.to_virtual_path(sandbox.path())) + ) + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn switches_to_ci_in_ci() { + let sandbox = create_empty_moon_sandbox(); + let plugin = sandbox + .create_toolchain_with_config("javascript", |cfg| { + cfg.host_environment(HostEnvironment { + ci: true, + ..Default::default() + }); + }) + .await; + + // Doesn't work without the lockfile + let output = plugin + .install_dependencies(InstallDependenciesInput { + root: VirtualPath::new(sandbox.path()), + toolchain_config: json!({ + "packageManager": "nub", + }), + ..Default::default() + }) + .await; + + assert_eq!( + output.install_command.unwrap(), + ExecCommand::new( + ExecCommandInput::new("nub", ["install"]) + .cwd(plugin.plugin.to_virtual_path(sandbox.path())) + ) + ); + + // Now works! + sandbox.create_file("nub.lock", ""); + + let output = plugin + .install_dependencies(InstallDependenciesInput { + root: VirtualPath::new(sandbox.path()), + toolchain_config: json!({ + "packageManager": "nub", + }), + ..Default::default() + }) + .await; + + assert_eq!( + output.install_command.unwrap(), + ExecCommand::new( + ExecCommandInput::new("nub", ["ci"]) + .cwd(plugin.plugin.to_virtual_path(sandbox.path())) + ) + ); + + // But not for production, since `nub ci` has no `--prod` + let output = plugin + .install_dependencies(InstallDependenciesInput { + root: VirtualPath::new(sandbox.path()), + toolchain_config: json!({ + "packageManager": "nub", + }), + production: true, + ..Default::default() + }) + .await; + + assert_eq!( + output.install_command.unwrap(), + ExecCommand::new( + ExecCommandInput::new("nub", ["install", "--prod"]) + .cwd(plugin.plugin.to_virtual_path(sandbox.path())) + ) + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn switches_to_ci_with_other_pm_lock() { + let sandbox = create_empty_moon_sandbox(); + sandbox.create_file("pnpm-lock.yaml", ""); + + let plugin = sandbox + .create_toolchain_with_config("javascript", |cfg| { + cfg.host_environment(HostEnvironment { + ci: true, + ..Default::default() + }); + }) + .await; + + let output = plugin + .install_dependencies(InstallDependenciesInput { + root: VirtualPath::new(sandbox.path()), + toolchain_config: json!({ + "packageManager": "nub", + }), + ..Default::default() + }) + .await; + + assert_eq!( + output.install_command.unwrap(), + ExecCommand::new( + ExecCommandInput::new("nub", ["ci"]) + .cwd(plugin.plugin.to_virtual_path(sandbox.path())) + ) + ); + } + } + mod pnpm { use super::*; @@ -2139,7 +2443,28 @@ mod javascript_toolchain_tier2 { }) .await; - assert_eq!(output.dependencies, expected_dependencies()); + // GitHub dependencies only exist in the bun lockfile fixture: + // with package metadata + integrity, and without either + let mut expected = expected_dependencies(); + expected.insert( + "@portkey-ai/gateway".into(), + vec![LockDependency { + hash: Some( + "sha512-71lqRjKGGxMSs4l6DdrzXHkGvitFdCsWxLbxYVfbYDTfhqOL0dJbmqjyrKObjSTbQqCXTh2XZQeI2Un0FN7pig==" + .into() + ), + ..Default::default() + }], + ); + expected.insert( + "uWebSockets.js".into(), + vec![LockDependency { + hash: Some("uNetworking-uWebSockets.js-6609a88".into()), + ..Default::default() + }], + ); + + assert_eq!(output.dependencies, expected); } // #[tokio::test(flavor = "multi_thread")] @@ -2249,6 +2574,22 @@ mod javascript_toolchain_tier2 { assert_eq!(output.dependencies, expected_dependencies()); } + // `nub.lock` uses the pnpm lockfile format + #[tokio::test(flavor = "multi_thread")] + async fn parses_nub() { + let sandbox = create_lockfile_sandbox("nub"); + let plugin = sandbox.create_toolchain("javascript").await; + + let output = plugin + .parse_lock(ParseLockInput { + path: VirtualPath::new(sandbox.path().join("nub.lock")), + ..Default::default() + }) + .await; + + assert_eq!(output.dependencies, expected_base_dependencies()); + } + #[tokio::test(flavor = "multi_thread")] async fn parses_pnpm() { let sandbox = create_lockfile_sandbox("pnpm"); diff --git a/toolchains/node-depman/CHANGELOG.md b/toolchains/node-depman/CHANGELOG.md index d1b09134..4b0b4bed 100644 --- a/toolchains/node-depman/CHANGELOG.md +++ b/toolchains/node-depman/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.1.0 + +#### 🚀 Updates + +- Added unstable support for [Nub](https://nubjs.com/). + ## 1.0.4 #### 🚀 Updates diff --git a/toolchains/node-depman/Cargo.toml b/toolchains/node-depman/Cargo.toml index ca4cb6bc..769cf0da 100644 --- a/toolchains/node-depman/Cargo.toml +++ b/toolchains/node-depman/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "node_depman_toolchain" -version = "1.0.4" +version = "1.1.0" edition = "2024" -description = "Node.js dependency managers (npm, pnpm, yarn) toolchain WASM plugin for moon." +description = "Node.js dependency managers (npm, pnpm, yarn, nub) toolchain WASM plugin for moon." authors = ["Miles Johnson"] license = "MIT" repository = "https://github.com/moonrepo/plugins" diff --git a/toolchains/node-depman/src/config.rs b/toolchains/node-depman/src/config.rs index a44e4459..f2d0d401 100644 --- a/toolchains/node-depman/src/config.rs +++ b/toolchains/node-depman/src/config.rs @@ -16,6 +16,20 @@ config_struct!( } ); +config_struct!( + /// Configures and enables the Nub toolchain. + /// Docs: https://moonrepo.dev/docs/config/toolchain#nub + #[derive(Config)] + pub struct NubToolchainConfig { + /// List of arguments to append to `nub install` commands. + /// These arguments are inherited by the JavaScript toolchain. + pub install_args: Vec, + + /// Configured version to download and install. + pub version: Option, + } +); + config_struct!( /// Configures and enables the pnpm toolchain. /// Docs: https://moonrepo.dev/docs/config/toolchain#pnpm diff --git a/toolchains/node-depman/src/tier1.rs b/toolchains/node-depman/src/tier1.rs index 3d091733..3ff2a6f0 100644 --- a/toolchains/node-depman/src/tier1.rs +++ b/toolchains/node-depman/src/tier1.rs @@ -21,7 +21,12 @@ pub fn register_toolchain( lock_file_names: vec!["package-lock.json".into(), "npm-shrinkwrap.json".into()], ..Default::default() }, - PackageManager::Nub => todo!(), + PackageManager::Nub => RegisterToolchainOutput { + config_file_globs: vec![".npmrc".into(), "nub.jsonc".into()], + exe_names: vec!["nub".into(), "nubx".into()], + lock_file_names: vec!["nub.lock".into()], + ..Default::default() + }, PackageManager::Pnpm | PackageManager::Pnpm11 | PackageManager::Pnpm12 => { RegisterToolchainOutput { config_file_globs: vec![ @@ -73,7 +78,7 @@ pub fn define_toolchain_config() -> FnResult> Ok(Json(DefineToolchainConfigOutput { schema: match manager { PackageManager::Npm => SchemaBuilder::build_root::(), - PackageManager::Nub => todo!(), + PackageManager::Nub => SchemaBuilder::build_root::(), PackageManager::Pnpm | PackageManager::Pnpm11 | PackageManager::Pnpm12 => { SchemaBuilder::build_root::() } diff --git a/toolchains/node-depman/src/tier2.rs b/toolchains/node-depman/src/tier2.rs index cd9babfc..0bad4297 100644 --- a/toolchains/node-depman/src/tier2.rs +++ b/toolchains/node-depman/src/tier2.rs @@ -5,14 +5,21 @@ use extism_pdk::*; use moon_pdk::parse_toolchain_config_schema; use moon_pdk_api::*; use node_depman_tool::PackageManager; -use proto_pdk_api::{UnresolvedVersionSpec, VersionSpec}; #[plugin_fn] pub fn define_requirements( Json(_): Json, ) -> FnResult> { Ok(Json(DefineRequirementsOutput { - requires: vec!["node".into()], + // Nub is a standalone binary that can manage Node.js itself, + // while the other package managers are Node.js scripts + requires: if PackageManager::detect()?.is_nub() { + vec![] + } else { + vec!["node".into()] + }, + for_setup_environment: false, + for_setup_toolchain: true, })) } @@ -27,19 +34,18 @@ pub fn setup_environment( if manager.is_yarn() { let config = parse_toolchain_config_schema::(input.toolchain_config)?; - // TODO fix once moon is on proto 0.59 - if let Some(incompat_version) = &config.version { - let compat_version = UnresolvedVersionSpec::parse(incompat_version.to_string())?; - let compat_spec = match &compat_version { + if let Some(compat_version) = &config.version { + let compat_spec = match compat_version { + UnresolvedVersionSpec::Range(_) => None, UnresolvedVersionSpec::Requirement(req) => { - VersionSpec::parse(format!("{}.0.0", req.major.unwrap_or_default()))? + VersionSpec::parse(format!("{}.0.0", req.major.unwrap_or_default())).ok() } - _ => compat_version.to_resolved_spec(), + other => Some(other.to_resolved_spec()), }; - let new_manager = PackageManager::detect_from_version(&compat_spec)?; - - if new_manager == PackageManager::Yarn2to5 { + if let Some(compat_spec) = compat_spec + && PackageManager::detect_from_version(&compat_spec)? == PackageManager::Yarn2to5 + { for plugin in config.plugins { output.commands.push(ExecCommand::new( ExecCommandInput::new("yarn", ["plugin", "import", &plugin]) diff --git a/toolchains/node-depman/tests/tier1_test.rs b/toolchains/node-depman/tests/tier1_test.rs index adfa0d49..4aace750 100644 --- a/toolchains/node-depman/tests/tier1_test.rs +++ b/toolchains/node-depman/tests/tier1_test.rs @@ -27,6 +27,23 @@ mod node_depman_toolchain_tier1 { assert_eq!(output.vendor_dir_name.unwrap(), "node_modules"); } + #[tokio::test(flavor = "multi_thread")] + async fn handles_nub() { + let sandbox = create_empty_moon_sandbox(); + let plugin = sandbox.create_toolchain("nub").await; + + let output = plugin + .register_toolchain(RegisterToolchainInput { id: Id::raw("nub") }) + .await; + + assert_eq!(output.name, "nub"); + assert_eq!(output.config_file_globs, [".npmrc", "nub.jsonc"]); + assert_eq!(output.manifest_file_names, ["package.json"]); + assert_eq!(output.lock_file_names, ["nub.lock"]); + assert_eq!(output.exe_names, ["nub", "nubx"]); + assert_eq!(output.vendor_dir_name.unwrap(), "node_modules"); + } + #[tokio::test(flavor = "multi_thread")] async fn handles_pnpm() { let sandbox = create_empty_moon_sandbox(); diff --git a/toolchains/node-depman/tests/tier2_test.rs b/toolchains/node-depman/tests/tier2_test.rs index 9e2dd39a..622aa93a 100644 --- a/toolchains/node-depman/tests/tier2_test.rs +++ b/toolchains/node-depman/tests/tier2_test.rs @@ -24,6 +24,22 @@ mod node_depman_toolchain_tier2 { assert!(output.commands.is_empty()); } + #[tokio::test(flavor = "multi_thread")] + async fn does_nothing_for_nub() { + let sandbox = create_empty_moon_sandbox(); + let plugin = sandbox.create_toolchain("nub").await; + + let output = plugin + .setup_environment(SetupEnvironmentInput { + root: VirtualPath::new(sandbox.path()), + toolchain_config: json!({}), + ..Default::default() + }) + .await; + + assert!(output.commands.is_empty()); + } + #[tokio::test(flavor = "multi_thread")] async fn does_nothing_for_pnpm() { let sandbox = create_empty_moon_sandbox(); diff --git a/toolchains/node/CHANGELOG.md b/toolchains/node/CHANGELOG.md index 605c3be2..8a18736a 100644 --- a/toolchains/node/CHANGELOG.md +++ b/toolchains/node/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.0.4 + +#### 🚀 Updates + +- Deprecated the `syncVersionManagerConfig` setting (it never worked correctly). + ## 1.0.3 #### 🚀 Updates diff --git a/toolchains/node/Cargo.toml b/toolchains/node/Cargo.toml index 45b1fc2c..f9d6f77b 100644 --- a/toolchains/node/Cargo.toml +++ b/toolchains/node/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "node_toolchain" -version = "1.0.3" +version = "1.0.4" edition = "2024" description = "Node.js toolchain WASM plugin for moon." authors = ["Miles Johnson"] diff --git a/toolchains/node/src/config.rs b/toolchains/node/src/config.rs index cdd0689d..44b7b116 100644 --- a/toolchains/node/src/config.rs +++ b/toolchains/node/src/config.rs @@ -36,6 +36,7 @@ config_struct!( pub profile_execution: Option, /// When `version` is defined, syncs the version to the chosen config. + #[deprecated] pub sync_version_manager_config: Option, /// Configured version to download and install. diff --git a/toolchains/node/src/tier2.rs b/toolchains/node/src/tier2.rs index 19366bff..e30f5b64 100644 --- a/toolchains/node/src/tier2.rs +++ b/toolchains/node/src/tier2.rs @@ -4,36 +4,6 @@ use crate::config::*; use extism_pdk::*; use moon_pdk::{VirtualPathExt, parse_toolchain_config}; use moon_pdk_api::*; -use starbase_utils::fs; - -#[plugin_fn] -pub fn setup_environment( - Json(input): Json, -) -> FnResult> { - let config = parse_toolchain_config::(input.toolchain_config)?; - let mut output = SetupEnvironmentOutput::default(); - - // Sync version manager - if let Some(version_manager) = config.sync_version_manager_config - && let Some(version) = config.version - { - let (op, file) = Operation::track("sync-version-manager", || { - let rc_path = input.root.join(match version_manager { - NodeVersionManager::Nodenv => ".node-version", - NodeVersionManager::Nvm => ".nvmrc", - }); - - fs::write_file(&rc_path, version.to_partial_string())?; - - Ok(rc_path) - })?; - - output.operations.push(op); - output.changed_files.push(file); - } - - Ok(Json(output)) -} #[plugin_fn] pub fn extend_task_command( diff --git a/toolchains/node/tests/tier2_test.rs b/toolchains/node/tests/tier2_test.rs index 1ca92d27..63d7bfa7 100644 --- a/toolchains/node/tests/tier2_test.rs +++ b/toolchains/node/tests/tier2_test.rs @@ -1,123 +1,10 @@ use moon_pdk_api::*; use moon_pdk_test_utils::create_empty_moon_sandbox; use serde_json::json; -use std::fs; mod node_toolchain_tier2 { use super::*; - mod setup_environment { - use super::*; - - mod sync_toolchain_config { - use super::*; - - #[tokio::test(flavor = "multi_thread")] - async fn does_nothing_if_no_version() { - let sandbox = create_empty_moon_sandbox(); - let plugin = sandbox.create_toolchain("node").await; - - let output = plugin - .setup_environment(SetupEnvironmentInput { - root: VirtualPath::new(sandbox.path()), - toolchain_config: json!({ - "syncVersionManagerConfig": "nvm", - "version": null - }), - ..Default::default() - }) - .await; - - assert!(output.operations.is_empty()); - assert!(output.changed_files.is_empty()); - } - - #[tokio::test(flavor = "multi_thread")] - async fn does_nothing_if_disabled() { - let sandbox = create_empty_moon_sandbox(); - let plugin = sandbox.create_toolchain("node").await; - - let output = plugin - .setup_environment(SetupEnvironmentInput { - root: VirtualPath::new(sandbox.path()), - toolchain_config: json!({ - "syncVersionManagerConfig": null, - "version": "20.1" - }), - ..Default::default() - }) - .await; - - assert!(output.operations.is_empty()); - assert!(output.changed_files.is_empty()); - } - - #[tokio::test(flavor = "multi_thread")] - async fn writes_nvm_version() { - let sandbox = create_empty_moon_sandbox(); - let plugin = sandbox.create_toolchain("node").await; - - let output = plugin - .setup_environment(SetupEnvironmentInput { - root: VirtualPath::new(sandbox.path()), - toolchain_config: json!({ - "syncVersionManagerConfig": "nvm", - "version": "20.1" - }), - ..Default::default() - }) - .await; - - assert!( - output - .operations - .iter() - .any(|op| op.id == "sync-version-manager") - ); - assert_eq!( - output.changed_files, - [VirtualPath::new("/workspace/.nvmrc")] - ); - assert_eq!( - fs::read_to_string(sandbox.path().join(".nvmrc")).unwrap(), - "20.1" - ); - } - - #[tokio::test(flavor = "multi_thread")] - async fn writes_nodenv_version() { - let sandbox = create_empty_moon_sandbox(); - let plugin = sandbox.create_toolchain("node").await; - - let output = plugin - .setup_environment(SetupEnvironmentInput { - root: VirtualPath::new(sandbox.path()), - toolchain_config: json!({ - "syncVersionManagerConfig": "nodenv", - "version": "20.1" - }), - ..Default::default() - }) - .await; - - assert!( - output - .operations - .iter() - .any(|op| op.id == "sync-version-manager") - ); - assert_eq!( - output.changed_files, - [VirtualPath::new("/workspace/.node-version")] - ); - assert_eq!( - fs::read_to_string(sandbox.path().join(".node-version")).unwrap(), - "20.1" - ); - } - } - } - mod extend_task_command { use super::*; diff --git a/toolchains/python-pip/CHANGELOG.md b/toolchains/python-pip/CHANGELOG.md index 8407898b..7866d3b2 100644 --- a/toolchains/python-pip/CHANGELOG.md +++ b/toolchains/python-pip/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.4 + +#### 🚀 Updates + +- Ensures that Python is installed before setting up this toolchain. + ## 0.1.3 #### 🚀 Updates diff --git a/toolchains/python-pip/Cargo.toml b/toolchains/python-pip/Cargo.toml index 50021c2d..fde3f354 100644 --- a/toolchains/python-pip/Cargo.toml +++ b/toolchains/python-pip/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "python_pip_toolchain" -version = "0.1.3" +version = "0.1.4" edition = "2024" description = "Python pip toolchain WASM plugin for moon." authors = ["Miles Johnson"] diff --git a/toolchains/python-pip/src/tier2.rs b/toolchains/python-pip/src/tier2.rs index 283eb09c..326ccb64 100644 --- a/toolchains/python-pip/src/tier2.rs +++ b/toolchains/python-pip/src/tier2.rs @@ -9,5 +9,8 @@ pub fn define_requirements( ) -> FnResult> { Ok(Json(DefineRequirementsOutput { requires: vec!["unstable_python".into()], + for_setup_environment: false, + // Requires python to run pip + for_setup_toolchain: true, })) } diff --git a/toolchains/python-poetry/CHANGELOG.md b/toolchains/python-poetry/CHANGELOG.md index 83e9ba1b..d8eed6fe 100644 --- a/toolchains/python-poetry/CHANGELOG.md +++ b/toolchains/python-poetry/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.2 + +#### 🚀 Updates + +- Ensures that Python is installed before setting up this toolchain. + ## 0.1.1 #### 🚀 Updates diff --git a/toolchains/python-poetry/Cargo.toml b/toolchains/python-poetry/Cargo.toml index 097c3c70..fc01546b 100644 --- a/toolchains/python-poetry/Cargo.toml +++ b/toolchains/python-poetry/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "python_poetry_toolchain" -version = "0.1.1" +version = "0.1.2" edition = "2024" description = "Python Poetry toolchain WASM plugin for moon." authors = ["Miles Johnson"] diff --git a/toolchains/python-poetry/src/tier2.rs b/toolchains/python-poetry/src/tier2.rs index 283eb09c..196b3690 100644 --- a/toolchains/python-poetry/src/tier2.rs +++ b/toolchains/python-poetry/src/tier2.rs @@ -9,5 +9,8 @@ pub fn define_requirements( ) -> FnResult> { Ok(Json(DefineRequirementsOutput { requires: vec!["unstable_python".into()], + for_setup_environment: false, + // Requires python to run poetry + for_setup_toolchain: true, })) } diff --git a/toolchains/python-uv/CHANGELOG.md b/toolchains/python-uv/CHANGELOG.md index 44374b21..77c825fa 100644 --- a/toolchains/python-uv/CHANGELOG.md +++ b/toolchains/python-uv/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.5 + +#### 🚀 Updates + +- Ensures that Python is installed before setting up this toolchain. + ## 0.1.4 #### 🚀 Updates diff --git a/toolchains/python-uv/Cargo.toml b/toolchains/python-uv/Cargo.toml index 88ce0f11..c96e0550 100644 --- a/toolchains/python-uv/Cargo.toml +++ b/toolchains/python-uv/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "python_uv_toolchain" -version = "0.1.4" +version = "0.1.5" edition = "2024" description = "Python uv toolchain WASM plugin for moon." authors = ["Miles Johnson"] diff --git a/toolchains/python-uv/src/tier2.rs b/toolchains/python-uv/src/tier2.rs index 283eb09c..aece7c9b 100644 --- a/toolchains/python-uv/src/tier2.rs +++ b/toolchains/python-uv/src/tier2.rs @@ -9,5 +9,8 @@ pub fn define_requirements( ) -> FnResult> { Ok(Json(DefineRequirementsOutput { requires: vec!["unstable_python".into()], + for_setup_environment: false, + // Aligns with other package managers + for_setup_toolchain: true, })) } diff --git a/toolchains/python/CHANGELOG.md b/toolchains/python/CHANGELOG.md index 8354a977..7206f760 100644 --- a/toolchains/python/CHANGELOG.md +++ b/toolchains/python/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.3.0 + +#### 🚀 Updates + +- Ensures that package manager toolchains are installed before setting up the environment. +- Added support to the Docker pruning workflow where we remove `.venv` directories for non-focused projects (those that were explicitly scaffolded). + ## 0.2.1 #### 🚀 Updates diff --git a/toolchains/python/Cargo.toml b/toolchains/python/Cargo.toml index 0699c8ec..6b654b0e 100644 --- a/toolchains/python/Cargo.toml +++ b/toolchains/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "python_toolchain" -version = "0.2.1" +version = "0.3.0" edition = "2024" description = "Python toolchain WASM plugin for moon." authors = ["Miles Johnson"] diff --git a/toolchains/python/src/tier1.rs b/toolchains/python/src/tier1.rs index d268336a..69b55596 100644 --- a/toolchains/python/src/tier1.rs +++ b/toolchains/python/src/tier1.rs @@ -1,9 +1,10 @@ use crate::config::{PythonPackageManager, PythonToolchainConfig}; use extism_pdk::*; use moon_config::LanguageType; -use moon_pdk::parse_toolchain_config; +use moon_pdk::{parse_toolchain_config, parse_toolchain_config_schema}; use moon_pdk_api::*; use schematic::SchemaBuilder; +use starbase_utils::fs; use starbase_utils::json::JsonValue; use toolchain_common::enable_tracing; @@ -141,3 +142,24 @@ pub fn define_docker_metadata( scaffold_globs: vec![], })) } + +#[plugin_fn] +pub fn prune_docker(Json(input): Json) -> FnResult> { + let config = parse_toolchain_config_schema::(input.toolchain_config)?; + let mut output = PruneDockerOutput::default(); + + if input.docker_config.delete_vendor_directories { + for dep in input.project_dependencies { + let dep_root = input.context.get_project_root(&dep); + let venv_dir = dep_root.join(&config.venv_name); + + if venv_dir.exists() { + fs::remove_dir_all(&venv_dir)?; + + output.changed_files.push(venv_dir); + } + } + } + + Ok(Json(output)) +} diff --git a/toolchains/python/src/tier2.rs b/toolchains/python/src/tier2.rs index 841364fd..bafffb5a 100644 --- a/toolchains/python/src/tier2.rs +++ b/toolchains/python/src/tier2.rs @@ -13,6 +13,27 @@ use pep508_rs::Requirement; use std::collections::BTreeMap; use std::path::PathBuf; +#[plugin_fn] +pub fn define_requirements( + Json(input): Json, +) -> FnResult> { + let config = parse_toolchain_config_schema::(input.toolchain_config)?; + + Ok(Json(DefineRequirementsOutput { + requires: match config.package_manager { + Some(package_manager) => vec![format!("unstable_{package_manager}")], + None => vec![], + }, + // We must ensure package managers are fully installed + // before we setup the environment, otherwise commands fail + for_setup_environment: true, + // However, we don't want this when we setup this toolchain, + // because the package managers depend on us, and it would + // introduce a cycle + for_setup_toolchain: false, + })) +} + #[plugin_fn] pub fn extend_project_graph( Json(input): Json, diff --git a/toolchains/python/tests/tier1_test.rs b/toolchains/python/tests/tier1_test.rs index a89d8588..a2d2b82a 100644 --- a/toolchains/python/tests/tier1_test.rs +++ b/toolchains/python/tests/tier1_test.rs @@ -1,3 +1,4 @@ +use moon_config::DockerPruneConfig; use moon_pdk_api::*; use moon_pdk_test_utils::create_empty_moon_sandbox; use serde_json::json; @@ -87,4 +88,136 @@ mod python_toolchain_tier1 { assert_eq!(output.default_image.unwrap(), "python:3.10"); } } + + mod prune_docker { + use super::*; + + fn create_project_fragment(id: &str) -> ProjectFragment { + ProjectFragment { + id: Id::raw(id), + source: id.into(), + ..Default::default() + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn does_nothing_if_disabled() { + let sandbox = create_empty_moon_sandbox(); + sandbox.create_file("a/.venv/pyvenv.cfg", ""); + + let plugin = sandbox.create_toolchain("python").await; + + let output = plugin + .prune_docker(PruneDockerInput { + docker_config: DockerPruneConfig { + delete_vendor_directories: false, + ..Default::default() + }, + project_dependencies: vec![create_project_fragment("a")], + root: VirtualPath::new(sandbox.path()), + toolchain_config: json!({}), + ..Default::default() + }) + .await; + + assert!(sandbox.path().join("a/.venv").exists()); + + assert!(output.changed_files.is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn does_nothing_if_no_venv_dirs() { + let sandbox = create_empty_moon_sandbox(); + let plugin = sandbox.create_toolchain("python").await; + + let output = plugin + .prune_docker(PruneDockerInput { + docker_config: DockerPruneConfig { + delete_vendor_directories: true, + ..Default::default() + }, + project_dependencies: vec![create_project_fragment("a")], + root: VirtualPath::new(sandbox.path()), + toolchain_config: json!({}), + ..Default::default() + }) + .await; + + assert!(output.changed_files.is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn removes_venv_dirs_from_dependency_projects() { + let sandbox = create_empty_moon_sandbox(); + sandbox.create_file("a/.venv/pyvenv.cfg", ""); + sandbox.create_file("b/.venv/pyvenv.cfg", ""); + sandbox.create_file("c/.venv/pyvenv.cfg", ""); + + let plugin = sandbox.create_toolchain("python").await; + + let output = plugin + .prune_docker(PruneDockerInput { + docker_config: DockerPruneConfig { + delete_vendor_directories: true, + ..Default::default() + }, + project_dependencies: vec![ + create_project_fragment("a"), + create_project_fragment("b"), + ], + root: VirtualPath::new(sandbox.path()), + toolchain_config: json!({}), + ..Default::default() + }) + .await; + + assert!(!sandbox.path().join("a/.venv").exists()); + assert!(!sandbox.path().join("b/.venv").exists()); + + // Not a dependency, so remains + assert!(sandbox.path().join("c/.venv").exists()); + + assert_eq!( + output.changed_files, + [ + VirtualPath::new("/workspace/a/.venv"), + VirtualPath::new("/workspace/b/.venv") + ] + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn removes_custom_named_venv_dirs() { + let sandbox = create_empty_moon_sandbox(); + sandbox.create_file("a/venv/pyvenv.cfg", ""); + sandbox.create_file("a/.venv/pyvenv.cfg", ""); + + let plugin = sandbox.create_toolchain("python").await; + + let output = plugin + .prune_docker(PruneDockerInput { + docker_config: DockerPruneConfig { + delete_vendor_directories: true, + ..Default::default() + }, + project_dependencies: vec![create_project_fragment("a")], + root: VirtualPath::new(sandbox.path()), + toolchain_config: json!({ + "venvName": "venv" + }), + ..Default::default() + }) + .await; + + assert!(!sandbox.path().join("a/venv").exists()); + + // Not the configured name, so remains + assert!(sandbox.path().join("a/.venv").exists()); + + assert_eq!( + output.changed_files, + [VirtualPath::new("/workspace/a/venv")] + ); + } + } } diff --git a/toolchains/rust/CHANGELOG.md b/toolchains/rust/CHANGELOG.md index 84ca2f04..4b18574f 100644 --- a/toolchains/rust/CHANGELOG.md +++ b/toolchains/rust/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.0.9 + +#### 🐞 Fixes + +- Fixed configured `bins` not being reinstalled when their binaries were + uninstalled or deleted outside of moon. Only missing binaries are now + installed. + ## 1.0.8 #### 🚀 Updates diff --git a/toolchains/rust/Cargo.toml b/toolchains/rust/Cargo.toml index 32025e56..87fee9b5 100644 --- a/toolchains/rust/Cargo.toml +++ b/toolchains/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rust_toolchain" -version = "1.0.8" +version = "1.0.9" edition = "2024" description = "Rust toolchain WASM plugin for moon." authors = ["Miles Johnson"] diff --git a/toolchains/rust/src/tier2_env.rs b/toolchains/rust/src/tier2_env.rs index 4f8260bc..47523b18 100644 --- a/toolchains/rust/src/tier2_env.rs +++ b/toolchains/rust/src/tier2_env.rs @@ -11,6 +11,20 @@ fn create_command(bin: &str, args: Vec<&str>, cwd: &VirtualPath) -> ExecCommand ExecCommand::new(ExecCommandInput::new(bin, args).cwd(cwd.to_owned())) } +fn is_bin_installed(env: &HostEnvironment, globals_dir: Option<&VirtualPath>, spec: &str) -> bool { + let Some(globals_dir) = globals_dir else { + return false; + }; + + // Entries may be suffixed with a version: `cargo-nextest@0.9.52` + let name = match spec.split_once('@') { + Some((prefix, _)) => prefix, + None => spec, + }; + + globals_dir.join(env.os.get_exe_name(name)).exists() +} + #[plugin_fn] pub fn setup_environment( Json(input): Json, @@ -66,68 +80,73 @@ pub fn setup_environment( if !config.bins.is_empty() { let env = get_host_environment()?; - // Only install if we can't find the binary - if input - .globals_dir - .is_none_or(|dir| !dir.join(env.os.get_exe_name("cargo-binstall")).exists()) - { - let binstall_package = if let Some(version) = &config.binstall_version { - format!("cargo-binstall@{version}") - } else { - "cargo-binstall".into() - }; - - output.commands.push( - create_command( - "cargo", - vec!["install", &binstall_package, "--force", "--locked"], - &input.root, - ) - .cache(CacheStrategy::Memory) - .label("cargo-binstall"), - ); - } - let mut force_bins = vec![]; let mut non_force_bins = vec![]; for bin in &config.bins { match bin { BinEntry::String(inner) => { - non_force_bins.push(inner.as_str()); + if !is_bin_installed(env, input.globals_dir.as_ref(), inner) { + non_force_bins.push(inner.as_str()); + } } BinEntry::Object(cfg) => { if cfg.local && env.ci { continue; } else if cfg.force { force_bins.push(cfg.bin.as_str()); - } else { + } else if !is_bin_installed(env, input.globals_dir.as_ref(), &cfg.bin) { non_force_bins.push(cfg.bin.as_str()); } } }; } - if !force_bins.is_empty() { - let mut args = vec!["binstall", "--no-confirm", "--log-level", "info", "--force"]; - args.extend(force_bins); - - output.commands.push( - create_command("cargo", args, &input.root) - .cache(CacheStrategy::Memory) - .label("cargo-bins-forced"), - ); - } - - if !non_force_bins.is_empty() { - let mut args = vec!["binstall", "--no-confirm", "--log-level", "info"]; - args.extend(non_force_bins); - - output.commands.push( - create_command("cargo", args, &input.root) + if !force_bins.is_empty() || !non_force_bins.is_empty() { + // Only install if we can't find the binary + if input + .globals_dir + .as_ref() + .is_none_or(|dir| !dir.join(env.os.get_exe_name("cargo-binstall")).exists()) + { + let binstall_package = if let Some(version) = &config.binstall_version { + format!("cargo-binstall@{version}") + } else { + "cargo-binstall".into() + }; + + output.commands.push( + create_command( + "cargo", + vec!["install", &binstall_package, "--force", "--locked"], + &input.root, + ) .cache(CacheStrategy::Memory) - .label("cargo-bins"), - ); + .label("cargo-binstall"), + ); + } + + if !force_bins.is_empty() { + let mut args = vec!["binstall", "--no-confirm", "--log-level", "info", "--force"]; + args.extend(force_bins); + + output.commands.push( + create_command("cargo", args, &input.root) + .cache(CacheStrategy::Memory) + .label("cargo-bins-forced"), + ); + } + + if !non_force_bins.is_empty() { + let mut args = vec!["binstall", "--no-confirm", "--log-level", "info"]; + args.extend(non_force_bins); + + output.commands.push( + create_command("cargo", args, &input.root) + .cache(CacheStrategy::Memory) + .label("cargo-bins"), + ); + } } } diff --git a/toolchains/rust/tests/tier2_env_test.rs b/toolchains/rust/tests/tier2_env_test.rs index 877150d2..d683eb98 100644 --- a/toolchains/rust/tests/tier2_env_test.rs +++ b/toolchains/rust/tests/tier2_env_test.rs @@ -510,8 +510,8 @@ mod rust_toolchain_tier2 { }) .await; - // binstall command - assert_eq!(output.commands.len(), 1); + // no binstall command either, since there's nothing to install + assert!(output.commands.is_empty()); } #[tokio::test(flavor = "multi_thread")] @@ -599,5 +599,142 @@ mod rust_toolchain_tier2 { .label("cargo-bins")] ); } + + // https://github.com/moonrepo/plugins/issues/137 + + #[tokio::test(flavor = "multi_thread")] + async fn only_installs_missing_bins() { + let sandbox = create_empty_moon_sandbox(); + sandbox.create_file(".cargo/bin/cargo-nextest", ""); + sandbox.create_file(".cargo/bin/cargo-nextest.exe", ""); + + let plugin = sandbox.create_toolchain("rust").await; + + let output = plugin + .setup_environment(SetupEnvironmentInput { + root: VirtualPath::new(sandbox.path()), + globals_dir: Some(VirtualPath::new(sandbox.path().join(".cargo/bin"))), + toolchain_config: json!({ + "bins": ["cargo-nextest", "just"] + }), + ..Default::default() + }) + .await; + + assert_eq!( + output.commands, + [ + ExecCommand::new( + ExecCommandInput::new( + "cargo", + ["install", "cargo-binstall", "--force", "--locked"], + ) + .cwd(plugin.plugin.to_virtual_path(sandbox.path())) + ) + .cache(CacheStrategy::Memory) + .label("cargo-binstall"), + ExecCommand::new( + ExecCommandInput::new( + "cargo", + ["binstall", "--no-confirm", "--log-level", "info", "just"], + ) + .cwd(plugin.plugin.to_virtual_path(sandbox.path())) + ) + .cache(CacheStrategy::Memory) + .label("cargo-bins") + ] + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn adds_no_commands_if_all_bins_installed() { + let sandbox = create_empty_moon_sandbox(); + sandbox.create_file(".cargo/bin/cargo-nextest", ""); + sandbox.create_file(".cargo/bin/cargo-nextest.exe", ""); + + let plugin = sandbox.create_toolchain("rust").await; + + let output = plugin + .setup_environment(SetupEnvironmentInput { + root: VirtualPath::new(sandbox.path()), + globals_dir: Some(VirtualPath::new(sandbox.path().join(".cargo/bin"))), + toolchain_config: json!({ + "bins": ["cargo-nextest"] + }), + ..Default::default() + }) + .await; + + assert!(output.commands.is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn skips_installed_versioned_bins() { + let sandbox = create_empty_moon_sandbox(); + sandbox.create_file(".cargo/bin/cargo-nextest", ""); + sandbox.create_file(".cargo/bin/cargo-nextest.exe", ""); + + let plugin = sandbox.create_toolchain("rust").await; + + let output = plugin + .setup_environment(SetupEnvironmentInput { + root: VirtualPath::new(sandbox.path()), + globals_dir: Some(VirtualPath::new(sandbox.path().join(".cargo/bin"))), + toolchain_config: json!({ + "bins": ["cargo-nextest@0.9.52"] + }), + ..Default::default() + }) + .await; + + assert!(output.commands.is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn always_installs_forced_bins() { + let sandbox = create_empty_moon_sandbox(); + sandbox.create_file(".cargo/bin/cargo-binstall", ""); + sandbox.create_file(".cargo/bin/cargo-binstall.exe", ""); + sandbox.create_file(".cargo/bin/cargo-nextest", ""); + sandbox.create_file(".cargo/bin/cargo-nextest.exe", ""); + + let plugin = sandbox.create_toolchain("rust").await; + + let output = plugin + .setup_environment(SetupEnvironmentInput { + root: VirtualPath::new(sandbox.path()), + globals_dir: Some(VirtualPath::new(sandbox.path().join(".cargo/bin"))), + toolchain_config: json!({ + "bins": [ + { + "bin": "cargo-nextest", + "force": true + } + ] + }), + ..Default::default() + }) + .await; + + assert_eq!( + output.commands, + [ExecCommand::new( + ExecCommandInput::new( + "cargo", + [ + "binstall", + "--no-confirm", + "--log-level", + "info", + "--force", + "cargo-nextest", + ], + ) + .cwd(plugin.plugin.to_virtual_path(sandbox.path())) + ) + .cache(CacheStrategy::Memory) + .label("cargo-bins-forced")] + ); + } } }