diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e79a51d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,389 @@ +root = true + +# All files +[*] +indent_style = space + +# Xml files +[*.xml] +indent_size = 2 + +# Xml project files +[*.{csproj,fsproj,vbproj,proj,slnx}] +indent_size = 2 + +# Xml config files +[*.{props,targets,config,nuspec}] +indent_size = 2 + +[*.json] +indent_size = 2 + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 2 +tab_width = 2 + +# New line preferences +insert_final_newline = false + +#### .NET Coding Conventions #### +[*.{cs,vb}] + +# Organize usings +dotnet_separate_import_directive_groups = true +dotnet_sort_system_directives_first = true +file_header_template = unset + +# this. and Me. preferences +dotnet_style_qualification_for_event = false:silent +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_property = false:silent + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent + +# Expression-level preferences +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_namespace_match_folder = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion +dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion + +# Field preferences +dotnet_style_readonly_field = true:warning + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:suggestion + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = none + +#### C# Coding Conventions #### +[*.cs] + +# var preferences +csharp_style_var_elsewhere = false:silent +csharp_style_var_for_built_in_types = false:silent +csharp_style_var_when_type_is_apparent = false:silent + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_extended_property_pattern = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_pattern_matching = true:silent +csharp_style_prefer_switch_expression = true:suggestion + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_prefer_static_anonymous_function = true:suggestion +csharp_prefer_static_local_function = true:warning +csharp_preferred_modifier_order = public,private,protected,internal,file,const,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:suggestion +csharp_style_prefer_readonly_struct = true:suggestion +csharp_style_prefer_readonly_struct_member = true:suggestion + +# Code-block preferences +csharp_prefer_braces = true:silent +csharp_prefer_simple_using_statement = true:suggestion +csharp_style_namespace_declarations = file_scoped:suggestion +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_prefer_top_level_statements = true:silent + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:silent + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### Naming styles #### +[*.{cs,vb}] + +# Naming rules + +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion +dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces +dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase + +dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion +dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters +dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase + +dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods +dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties +dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.events_should_be_pascalcase.symbols = events +dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables +dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase + +dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants +dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase + +dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion +dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters +dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase + +dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields +dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion +dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields +dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase + +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase + +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums +dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions +dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase + +# Symbol specifications + +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interfaces.required_modifiers = + +dotnet_naming_symbols.enums.applicable_kinds = enum +dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.enums.required_modifiers = + +dotnet_naming_symbols.events.applicable_kinds = event +dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.events.required_modifiers = + +dotnet_naming_symbols.methods.applicable_kinds = method +dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.methods.required_modifiers = + +dotnet_naming_symbols.properties.applicable_kinds = property +dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.properties.required_modifiers = + +dotnet_naming_symbols.public_fields.applicable_kinds = field +dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_fields.required_modifiers = + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_fields.required_modifiers = + +dotnet_naming_symbols.private_static_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_fields.required_modifiers = static + +dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum +dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types_and_namespaces.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +dotnet_naming_symbols.type_parameters.applicable_kinds = namespace +dotnet_naming_symbols.type_parameters.applicable_accessibilities = * +dotnet_naming_symbols.type_parameters.required_modifiers = + +dotnet_naming_symbols.private_constant_fields.applicable_kinds = field +dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_constant_fields.required_modifiers = const + +dotnet_naming_symbols.local_variables.applicable_kinds = local +dotnet_naming_symbols.local_variables.applicable_accessibilities = local +dotnet_naming_symbols.local_variables.required_modifiers = + +dotnet_naming_symbols.local_constants.applicable_kinds = local +dotnet_naming_symbols.local_constants.applicable_accessibilities = local +dotnet_naming_symbols.local_constants.required_modifiers = const + +dotnet_naming_symbols.parameters.applicable_kinds = parameter +dotnet_naming_symbols.parameters.applicable_accessibilities = * +dotnet_naming_symbols.parameters.required_modifiers = + +dotnet_naming_symbols.public_constant_fields.applicable_kinds = field +dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_constant_fields.required_modifiers = const + +dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.local_functions.applicable_kinds = local_function +dotnet_naming_symbols.local_functions.applicable_accessibilities = * +dotnet_naming_symbols.local_functions.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascalcase.required_prefix = +dotnet_naming_style.pascalcase.required_suffix = +dotnet_naming_style.pascalcase.word_separator = +dotnet_naming_style.pascalcase.capitalization = pascal_case + +dotnet_naming_style.ipascalcase.required_prefix = I +dotnet_naming_style.ipascalcase.required_suffix = +dotnet_naming_style.ipascalcase.word_separator = +dotnet_naming_style.ipascalcase.capitalization = pascal_case + +dotnet_naming_style.tpascalcase.required_prefix = T +dotnet_naming_style.tpascalcase.required_suffix = +dotnet_naming_style.tpascalcase.word_separator = +dotnet_naming_style.tpascalcase.capitalization = pascal_case + +dotnet_naming_style._camelcase.required_prefix = _ +dotnet_naming_style._camelcase.required_suffix = +dotnet_naming_style._camelcase.word_separator = +dotnet_naming_style._camelcase.capitalization = camel_case + +dotnet_naming_style.camelcase.required_prefix = +dotnet_naming_style.camelcase.required_suffix = +dotnet_naming_style.camelcase.word_separator = +dotnet_naming_style.camelcase.capitalization = camel_case + +dotnet_naming_style.s_camelcase.required_prefix = s_ +dotnet_naming_style.s_camelcase.required_suffix = +dotnet_naming_style.s_camelcase.word_separator = +dotnet_naming_style.s_camelcase.capitalization = camel_case + diff --git a/.gitignore b/.gitignore index b45a037..9d523b9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ Build +bin +artifacts +src/ModuleFast/obj +src/ModuleFast/bin diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..23dfb1e --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,6 @@ + + + + true + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..3175bb8 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,10 @@ + + + + true + + + + + + diff --git a/ModuleFast.build.ps1 b/ModuleFast.build.ps1 index 48b55ce..5885413 100644 --- a/ModuleFast.build.ps1 +++ b/ModuleFast.build.ps1 @@ -34,6 +34,12 @@ Task Clean { } } +Task BuildCSharp { + $csprojPath = Join-Path $PSScriptRoot 'src' 'ModuleFast' 'ModuleFast.csproj' + # Artifacts Output Layout managed by Directory.Build.props — no -o needed + dotnet build $csprojPath --nologo -c Release +} + Task CopyFiles { Copy-Item @c -Path @( 'ModuleFast.psd1' @@ -41,6 +47,12 @@ Task CopyFiles { 'LICENSE' ) -Destination $ModuleOutFolderPath Copy-Item @c -Path 'ModuleFast.ps1' -Destination $Destination + + # Copy DLL and its dependencies from Artifacts Output to the module bin folder + $artifactsBinPath = Join-Path $PSScriptRoot 'artifacts' 'bin' 'ModuleFast' 'release' + $moduleBinPath = Join-Path $ModuleOutFolderPath 'bin' 'ModuleFast' + New-Item -ItemType Directory -Path $moduleBinPath -Force | Out-Null + Copy-Item @c -Path (Join-Path $artifactsBinPath '*') -Destination $moduleBinPath -Recurse } Task Version { @@ -93,6 +105,7 @@ Task Package Package.Nuget, Package.Zip #Supported High Level Tasks Task Build @( 'Clean' + 'BuildCSharp' 'CopyFiles' 'Version' 'GetNugetVersioningAssembly' diff --git a/ModuleFast.psd1 b/ModuleFast.psd1 index 054a53b..c08143c 100644 --- a/ModuleFast.psd1 +++ b/ModuleFast.psd1 @@ -69,10 +69,10 @@ # NestedModules = @() # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. - FunctionsToExport = 'Install-ModuleFast', 'Get-ModuleFastPlan', 'Clear-ModuleFastCache' + FunctionsToExport = @() # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. - #CmdletsToExport = '*' + CmdletsToExport = 'Install-ModuleFast', 'Get-ModuleFastPlan', 'Clear-ModuleFastCache', 'Import-ModuleManifest' # Variables to export from this module #VariablesToExport = '*' diff --git a/ModuleFast.psm1 b/ModuleFast.psm1 index 51baf96..a4ab538 100644 --- a/ModuleFast.psm1 +++ b/ModuleFast.psm1 @@ -1,2313 +1,40 @@ #requires -version 7.2 -using namespace Microsoft.PowerShell.Commands -using namespace NuGet.Versioning -using namespace System.Collections -using namespace System.Collections.Concurrent -using namespace System.Collections.Generic -using namespace System.Collections.Specialized -using namespace System.IO -using namespace System.IO.Compression -using namespace System.IO.Pipelines -using namespace System.Management.Automation -using namespace System.Management.Automation.Language -using namespace System.Net -using namespace System.Net.Http -using namespace System.Reflection -using namespace System.Runtime.Caching -using namespace System.Text -using namespace System.Threading -using namespace System.Threading.Tasks -#Because we are changing state, we want to be safe -#TODO: Implement logic to only fail on module installs, such that one module failure doesn't prevent others from installing. -#Probably need to take into account inconsistent state, such as if a dependent module fails then the depending modules should be removed. -$ErrorActionPreference = 'Stop' +# Load the C# binary module — probe Artifacts Output Layout paths first, then +# the classic deployed path (bin/ModuleFast/ModuleFast.dll). +$binaryModulePath = @( + # Classic deployed layout in same folder + (Join-Path $PSScriptRoot 'ModuleFast.dll') + # Artifacts Output Layout: dotnet build (debug, default) + (Join-Path $PSScriptRoot 'artifacts' 'bin' 'ModuleFast' 'debug' 'ModuleFast.dll') + # Artifacts Output Layout: dotnet build -c Release + (Join-Path $PSScriptRoot 'artifacts' 'bin' 'ModuleFast' 'release' 'ModuleFast.dll') +) | Where-Object { Test-Path $_ } | Select-Object -First 1 -if ($ENV:CI) { - Write-Verbose 'CI Environment Variable is set, this indicates a Continuous Integration System is being used. ModuleFast will suppress prompts by setting ConfirmPreference to None and forcing confirmations to false. This is to ensure that ModuleFast can be used in CI/CD systems without user interaction.' - #Module Scope which should carry to other called commands - $SCRIPT:ConfirmPreference = 'None' - $PSDefaultParameterValues['Install-ModuleFast:Confirm'] = $false +if (-not $binaryModulePath) { + Write-Warning "Binary module DLL not found in expected paths. The module will be imported without the binary component, which will likely cause it to not function. Expected paths were: 'artifacts/bin/ModuleFast/debug/ModuleFast.dll', 'artifacts/bin/ModuleFast/release/ModuleFast.dll', and 'bin/ModuleFast/ModuleFast.dll' relative to the module root." } -#Default Source is PWSH Gallery -$SCRIPT:DefaultSource = 'https://pwsh.gallery/index.json' +Write-Debug "Importing binary module from path: $binaryModulePath" +Import-Module $binaryModulePath -Force -#region Public - -enum InstallScope { - CurrentUser - AllUsers -} - - - -function Install-ModuleFast { - <# - .SYNOPSIS - High performance, declarative Powershell Module Installer - .DESCRIPTION - ModuleFast is a high performance, declarative PowerShell module installer. It is optimized for speed and written primarily in PowerShell and can be bootstrapped in a single line of code. It is ideal for Continuous Integration/Deployment and serverless scenarios where you want to install modules quickly and without any user interaction. It is inspired by PNPm (https://pnpm.io/) and other high performance declarative package managers. - - ModuleFast accepts a variety of familiar PowerShell syntaxes and objects for module specification as well as a custom shorthand syntax allowing complex version requirements to be defined in a single string. - - ModuleFast can also install the required modules specified in the #Requires line of a script, or in the RequiredModules section of a module manifest, by simplying providing the path to that file in the -Path parameter (which also accepts remote UNC, http, and https URLs). - - --------------------------- - Module Specification Syntax - --------------------------- - ModuleFast supports a shorthand string syntax for defining module specifications. It generally takes the form of ''. The version supports SemVer 2 and prerelease tags. - - The available operators are: - - '=': Exact version match. Examples: 'ImportExcel=7.1.0', 'ImportExcel=7.1.0-preview' - - '>': Greater than. Example: 'ImportExcel>7.1.0' - - '>=': Greater than or equal to. Example: 'ImportExcel>=7.1.0' - - '<': Less than. Example: 'ImportExcel<7.1.0' - - '<=': Less than or equal to. Example: 'ImportExcel<=7.1.0' - - '!': A prerelease operator that can be present at the beginning or end of a module name to indicate that prerelease versions are acceptable. Example: 'ImportExcel!', '!ImportExcel'. It can be combined with the other operators like so: 'ImportExcel!>7.1.0' - - ':': Lets you specify a NuGet version range. Example: 'ImportExcel:(7.0.0, 7.2.1-preview]' - - For more information about NuGet version range syntax used with the ':' operator: https://learn.microsoft.com/en-us/nuget/concepts/package-versioning#version-ranges. Wilcards are supported with this syntax e.g. 'ImportExcel:3.2.*' will install the latest 3.2.x version. - - ModuleFast also fully supports the ModuleSpecification object and hashtable-like string syntaxes that are used by Install-Module and Install-PSResource. More information on this format: https://learn.microsoft.com/en-us/dotnet/api/microsoft.powershell.commands.modulespecification?view=powershellsdk-7.4.0 - - ------- - Logging - ------- - Modulefast has extensive Verbose and Debug information available if you specify the -Verbose and/or -Debug parameters. This can be useful for troubleshooting or understanding how ModuleFast is working. Verbose level provides a high level "what" view of the process of module selection, while Debug level provides a much more detailed "Why" information about the module selection and installation process that can be useful in troubleshooting issues. - - ----------------- - Installation Path - ----------------- - ModuleFast will install modules to the default PowerShell module path on non-Windows platforms. On Windows, it will install to %LOCALAPPDATA%\PowerShell\Modules by default as the default Documents PowerShell Modules folder has increasing caused problems due to conflicts with document syncing programs such as OneDrive. You can override this behavior by specifying the -Destination parameter. You can also specify 'CurrentUser' to install to the legacy Documents PowerShell Modules folder on Windows only. - - As part of this installation process on Windows, ModuleFast will add the destination to your PSModulePath for the current session. This is done to ensure that the modules are available for use in the current session. If you do not want this behavior, you can specify the -NoPSModulePathUpdate switch. - - In addition, if you do not already have the %LOCALAPPDATA%\PowerShell\Modules in your $env:PSModulesPath, Modulefast will append a command to add it to your user profile. This is done to ensure that the modules are available for use in future sessions. If you do not want this behavior, you can specify the -NoProfileUpdate switch. - - ------- - Caching - ------- - ModuleFast will cache the results of the module selection process in memory for the duration of the PowerShell session. This is done to improve performance when multiple modules are being installed. If you want to clear the cache, you can call Clear-ModuleFastCache. - - .PARAMETER WhatIf - Outputs the installation plan of modules not already available and needing to be installed to the pipeline as well as the console. This can be saved and provided to Install-ModuleFast at a later date. - - .EXAMPLE - Install-ModuleFast 'ImportExcel' -PassThru - - Installs the latest version of ImportExcel and outputs info about the installed modules. Remove -PassThru for a quieter installation, or add -Verbose or -Debug for even more information. - - --- RESULT --- - Name ModuleVersion - ---- ------------- - ImportExcel 7.8.6 - - .EXAMPLE - Install-ModuleFast 'ImportExcel','VMware.PowerCLI.Sdk=12.6.0.19600125','PowerConfig<0.1.6','Az.Compute:7.1.*' -WhatIf - - Prepares an install plan for the latest version of ImportExcel, a specific version of VMware.PowerCLI.Sdk, a version of PowerConfig less than 0.1.6, and the latest version of Az.Compute that is in the 7.1.x range. - - --- RESULT --- - Name ModuleVersion - ---- ------------- - VMware.PowerCLI.Sdk 12.6.0.19600125 - ImportExcel 7.8.6 - PowerConfig 0.1.5 - Az.Compute 7.1.0 - VMware.PowerCLI.Sdk.Types 12.6.0.19600125 - Az.Accounts 2.13.2 - - .EXAMPLE - 'ImportExcel','VMware.PowerCLI.Sdk=12.6.0.19600125','PowerConfig<0.1.6','Az.Compute:7.1.*' | Install-ModuleFast -WhatIf - - Same as the previous example, but using the pipeline to provide the module specifications. - - --- RESULT --- - Name ModuleVersion - ---- ------------- - VMware.PowerCLI.Sdk 12.6.0.19600125 - ImportExcel 7.8.6 - PowerConfig 0.1.5 - Az.Compute 7.1.0 - VMware.PowerCLI.Sdk.Types 12.6.0.19600125 - Az.Accounts 2.13.2 - - - .EXAMPLE - $plan = Install-ModuleFast 'ImportExcel' -WhatIf - $plan | Install-ModuleFast - - Stores an Install Plan for ImportExcel in the $plan variable, then installs it. The later install can be done later, and the $plan objects are serializable to CLIXML/JSON/etc. for storage. - - --- RESULT --- - - What if: Performing the operation "Install 1 Modules" on target "C:\Users\JGrote\AppData\Local\powershell\Modules". Name ModuleVersion - - Name ModuleVersion - ---- ------------- - ImportExcel 7.8.6 - - .EXAMPLE - @{ModuleName='ImportExcel';ModuleVersion='7.8.6'} | Install-ModuleFast - - Installs a specific version of ImportExcel using - - .EXAMPLE - Install-ModuleFast 'ImportExcel' -Destination 'CurrentUser' - - Installs ImportExcel to the legacy PowerShell Modules folder in My Documents on Windows only, but does not update the PSModulePath or the user profile to include the new module path. This behavior is similar to Install-Module or Install-PSResource. - - .EXAMPLE - Install-ModuleFast -Path 'path\to\RequiresScript.ps1' -WhatIf - - Prepares a plan to install the dependencies defined in the #Requires statement of a script if a .ps1 is specified, the #Requires statement of a module if .psm1 is specified, or the RequiredModules section of a .psd1 file if it is a PowerShell Module Manifest. This is useful for quickly installing dependencies for scripts or modules. - - RequiresScript.ps1 Contents: - #Requires -Module PreReleaseTest - - --- RESULT --- - - What if: Performing the operation "Install 1 Modules" on target "C:\Users\JGrote\AppData\Local\powershell\Modules". - - Name ModuleVersion - ---- ------------- - PrereleaseTest 0.0.1 - - .EXAMPLE - Install-ModuleFast -WhatIf - - If no Specification or Path parameter is provided, ModuleFast will search for a .requires.json or a .requires.psd1 file in the current directory and install the modules specified in that file. This is useful for quickly installing dependencies for scripts or modules during a CI build or Github Action. - - Note that for this format you can explicitly specify 'latest' or ':*' as the version to install the latest version of a module. - - Module.requires.psd1 Contents: - @{ - ImportExcel = 'latest' - 'VMware.PowerCLI.Sdk' = '>=12.6.0.19600125' - } - - --- RESULT --- - - Name ModuleVersion - ---- ------------- - ImportExcel 7.8.6 - VMware.PowerCLI.Sdk 12.6.0.19600125 - VMware.PowerCLI.Sdk.Types 12.6.0.19600125 - - .EXAMPLE - Install-ModuleFast 'ImportExcel' -CI #This will write a lockfile to the current directory - Install-ModuleFast -CI #This will use the previously created lockfile to install same state as above. - - If the -CI switch is specified, ModuleFast will write a lockfile to the current directory indicating all modules that were installed. This lockfile will contain the exact versions of the modules that were installed. If the lockfile is present in the future, ModuleFast will only install the versions specified in the lockfile, which is useful for reproducing CI builds even if newer versions of modules are releases that match the initial specification. - - For instance, the above will install the latest version of ImportExcel (7.8.6 as of this writing) but will not install newer while modulefast is in this directory until the lockfile is removed or replaced. - - #> - - [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Specification')] - param( - #The module(s) to install. This can be a string, a ModuleSpecification, a hashtable with nuget version style (e.g. @{Name='test';Version='1.0'}), a hashtable with ModuleSpecification style (e.g. @{Name='test';RequiredVersion='1.0'}), - [Alias('Name')] - [Alias('ModuleToInstall')] - [Alias('ModulesToInstall')] - [AllowNull()] - [AllowEmptyCollection()] - [Parameter(Position = 0, ValueFromPipeline, ParameterSetName = 'Specification')][ModuleFastSpec[]]$Specification, - - #Provide a required module specification path to install from. This can be a local psd1/json file, or a remote URL with a psd1/json file in supported manifest formats, or a .ps1/.psm1 file with a #Requires statement. - [Parameter(Mandatory, ParameterSetName = 'Path')][string]$Path, - #Explicitly specify the type of SpecFile to use. ModuleFast has some limited autodetection capability for ModuleBuilder and PSDepend formats, you should use this parameter if they explicitly fail. This is ignored if the file is not a .psd1 file. - [Parameter(ParameterSetName = 'Path')][SpecFileType]$SpecFileType = [SpecFileType]::AutoDetect, - #Where to install the modules. This defaults to the builtin module path on non-windows and a custom LOCALAPPDATA location on Windows. You can also specify 'CurrentUser' to install to the Documents folder on Windows Only (this is not recommended) - [string]$Destination, - #The repository to scan for modules. TODO: Multi-repo support - [string]$Source = $SCRIPT:DefaultSource, - #The credential to use to authenticate. Only basic auth is supported - [PSCredential]$Credential, - #By default will modify your PSModulePath to use the builtin destination if not present. Setting this implicitly skips profile update as well. - [Switch]$NoPSModulePathUpdate, - #Setting this won't add the default destination to your powershell.config.json. This really only matters on Windows. - [Switch]$NoProfileUpdate, - #Setting this will check for newer modules if your installed modules are not already at the upper bound of the required version range. Note that specifying this will also clear the local request cache for remote repositories which will result in slower evaluations if the information has not changed. - [Switch]$Update, - #Prerelease packages will be included in ModuleFast evaluation. If a non-prerelease package has a prerelease dependency, that dependency will be included regardless of this setting. If this setting is specified, all packages will be evaluated for prereleases regardless of if they have a prerelease indicator such as '!' in their specification name, but will still be subject to specification version constraints that would prevent a prerelease from installing. - [Switch]$Prerelease, - #Using the CI switch will write a lockfile to the current folder. If this file is present and -CI is specified in the future, ModuleFast will only install the versions specified in the lockfile, which is useful for reproducing CI builds even if newer versions of software come out. - [Switch]$CI, - #Only consider the specified destination and not any other paths currently in the PSModulePath. This is useful for CI scenarios where you want to ensure that the modules are installed in a specific location. - [Switch]$DestinationOnly, - #How many concurrent installation threads to run. Each installation thread, given sufficient bandwidth, will likely saturate a full CPU core with decompression work. This defaults to the number of logical cores on the system. If your system uses HyperThreading and presents more logical cores than physical cores available, you may want to set this to half your number of logical cores for best performance. - [int]$ThrottleLimit = [Environment]::ProcessorCount, - #The path to the lockfile. By default it is requires.lock.json in the current folder. This is ignored if -CI parameter is not present. It is generally not recommended to change this setting. - [string]$CILockFilePath = $(Join-Path $PWD 'requires.lock.json'), - #A list of ModuleFastInfo objects to install. This parameterset is used when passing a plan to ModuleFast via the pipeline and is generally not used directly. - [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'ModuleFastInfo')][ModuleFastInfo[]]$ModuleFastInfo, - #Outputs the installation plan of modules not already available and needing to be installed to the pipeline as well as the console. This can be saved and provided to Install-ModuleFast at a later date. This is functionally the same as -WhatIf but without the additional WhatIf Output - [Switch]$Plan, - #This will output the resulting modules that were installed. - [Switch]$PassThru, - #Setting this to "CurrentUser" is the same as specifying the destination as 'Current'. This is a usability convenience. - [InstallScope]$Scope, - #The timeout for HTTP requests. This is set to 30 seconds by default. This is generally sufficient for most requests, but you may need to increase this if you are on a slow connection or are downloading large modules. - [int]$Timeout = 30, - #ModuleFast performs some friendly operations that aren't strictly SemVer compliant. For example, if you ask for Module!<3.0.0, technically 3.0.0-alpha should be returned via the SemVer spec, but typically that's not what people actually want, they want what would effectively be Module!<2.999.999, so we exclude these prereleases for good UX. Specify this switch to enforce strict SemVer behavior and return the prerelease in this scenario. - [switch]$StrictSemVer - ) - begin { - trap {$PSCmdlet.ThrowTerminatingError($PSItem)} - #Clear the ModuleFastCache if -Update is specified to ensure fresh lookups of remote module availability - if ($Update) { - Clear-ModuleFastCache - } - - #Cleanup that allows for shorthand such as pwsh.gallery - [Uri]$srcTest = $Source - if ($srcTest.Scheme -notin 'http', 'https') { - Write-Debug "Appending https and index.json to $Source" - $Source = "https://$Source/index.json" - } - - $defaultRepoPath = $(Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'powershell/Modules') - if (-not $Destination) { - #Special function that will retrieve the default module path for the current user - $Destination = Get-PSDefaultModulePath -AllUsers:($Scope -eq 'AllUsers') - - #Special case for Windows to avoid the default installation path because it has issues with OneDrive - $defaultWindowsModulePath = $isWindows ? (Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'PowerShell/Modules') : 'XXX___NOTSUPPORTED' - - if ($IsWindows -and $Destination -eq $defaultWindowsModulePath -and $Scope -ne 'CurrentUser') { - Write-Debug "Windows Documents module folder detected. Changing to $defaultRepoPath" - $Destination = $defaultRepoPath - } - } - - if (-not $Destination) { - throw 'Failed to determine destination path. This is a bug, please report it, it should always have something by this point.' - } - - - - # Require approval to create the destination folder if it is not our default path, otherwise this is automatic - if (-not (Test-Path $Destination)) { - if ($configRepoPath -or - $Destination -eq $defaultRepoPath -or - (Approve-Action 'Create Destination Folder' $Destination) - ) { - New-Item -ItemType Directory -Path $Destination -Force | Out-Null - } - } - - $Destination = Resolve-Path $Destination - - if (-not $NoPSModulePathUpdate) { - # Get the current PSModulePath - $PSModulePaths = $env:PSModulePath.Split([Path]::PathSeparator, [StringSplitOptions]::RemoveEmptyEntries) - - #Only update if the module path is not already in the PSModulePath - if ($Destination -notin $PSModulePaths) { - Add-DestinationToPSModulePath -Destination $Destination -NoProfileUpdate:$NoProfileUpdate -Confirm:$Confirm - } - } - - #We want to maintain a single HttpClient for the life of the module. This isn't as big of a deal as it used to be but - #it is still a best practice. - if (-not $SCRIPT:__ModuleFastHttpClient -or $Source -ne $SCRIPT:__ModuleFastHttpClient.BaseAddress) { - $SCRIPT:__ModuleFastHttpClient = New-ModuleFastClient -Credential $Credential -Timeout $Timeout - if (-not $SCRIPT:__ModuleFastHttpClient) { - throw 'Failed to create ModuleFast HTTPClient. This is a bug' - } - } - $httpClient = $SCRIPT:__ModuleFastHttpClient - - $cancelSource = [CancellationTokenSource]::new() - $cancelSource.CancelAfter([TimeSpan]::FromSeconds($Timeout)) - - [HashSet[ModuleFastSpec]]$ModulesToInstall = @() - [List[ModuleFastInfo]]$installPlan = @() - } - - process { - #We initialize and type the container list here because there is a bug where the ParameterSet is not correct in the begin block if the pipeline is used. Null conditional keeps it from being reinitialized - - switch ($PSCmdlet.ParameterSetName) { - 'Specification' { - foreach ($ModuleToInstall in $Specification) { - $isDuplicate = -not $ModulesToInstall.Add($ModuleToInstall) - if ($isDuplicate) { - Write-Warning "$ModuleToInstall was specified twice, skipping duplicate" - } - } - break - - } - 'ModuleFastInfo' { - foreach ($info in $ModuleFastInfo) { - $installPlan.Add($info) - } - break - } - 'Path' { - $Paths = @() - #Search for a spec file if a directory was provided - if ('Directory' -in (Get-Item $Path).Attributes) { - $Paths += Find-RequiredSpecFile -Path $Path - } else { - $Paths = $Path - } - foreach ($pathItem in $Paths) { - $ModulesToInstall = ConvertFrom-RequiredSpec -RequiredSpecPath $pathItem -SpecFileType $SpecFileType - } - } - } - } - - end { - trap {$PSCmdlet.ThrowTerminatingError($PSItem)} - try { - if (-not $installPlan) { - if ($ModulesToInstall.Count -eq 0 -and $PSCmdlet.ParameterSetName -eq 'Specification') { - Write-Verbose '🔎 No modules specified to install. Beginning SpecFile detection...' - $modulesToInstall = if ($CI -and (Test-Path $CILockFilePath)) { - Write-Debug "Found lockfile at $CILockFilePath. Using for specification evaluation and ignoring all others." - ConvertFrom-RequiredSpec -RequiredSpecPath $CILockFilePath -SpecFileType $SpecFileType - if ($Update) { - Write-Verbose "-Update was specified but a lockfile was found. Ignoring -Update and using lockfile specification." - $Update = $false - } - } else { - $specFiles = Find-RequiredSpecFile $PWD -CILockFileHint $CILockFilePath - if (-not $specFiles) { - Write-Warning "No specfiles found in $PWD. Please ensure you have a .requires.json or .requires.psd1 file in the current directory, specify a path with -Path, or define specifications with the -Specification parameter to skip this search." - } - foreach ($specfile in $specFiles) { - Write-Verbose "Found Specfile $specFile. Evaluating..." - ConvertFrom-RequiredSpec -RequiredSpecPath $specFile -SpecFileType $SpecFileType - } - } - } - - if (-not $ModulesToInstall) { - throw [InvalidDataException]'No modules specifications found to evaluate.' - } - - #If we do not have an explicit implementation plan, fetch it - #This is done so that Get-ModuleFastPlan | Install-ModuleFastPlan and Install-ModuleFastPlan have the same flow. - [ModuleFastInfo[]]$installPlan = if ($PSCmdlet.ParameterSetName -eq 'ModuleFastInfo') { - $ModulesToInstall.ToArray() - } else { - Write-Progress -Id 1 -Activity 'Install-ModuleFast' -Status 'Plan' -PercentComplete 1 - $getPlanParams = @{ - Specification = $ModulesToInstall - HttpClient = $httpClient - Source = $Source - Update = $Update - PreRelease = $Prerelease.IsPresent - DestinationOnly = $DestinationOnly - Destination = $Destination - Timeout = $Timeout - StrictSemVer = $StrictSemVer - } - Get-ModuleFastPlan @getPlanParams - } - } - - if ($installPlan.Count -eq 0) { - $planAlreadySatisfiedMessage = "`u{2705} $($ModulesToInstall.count) Module Specifications have all been satisfied by installed modules. If you would like to check for newer versions remotely, specify -Update" - if ($WhatIfPreference) { - Write-Host -fore DarkGreen $planAlreadySatisfiedMessage - } else { - Write-Verbose $planAlreadySatisfiedMessage - } - return - } - - #Unless Plan was specified, run the process (WhatIf will also short circuit). - #Plan is specified first so that WhatIf message will only show if Plan is not specified due to -or short circuit logic. - if ($Plan -or -not (Approve-Action $Destination "Install $($installPlan.Count) Modules")) { - if ($Plan) { - Write-Verbose "📑 -Plan was specified. Returning a plan including $($installPlan.Count) Module Specifications" - } - #TODO: Separate planned installs and dependencies. Can probably do this with a dependency flag on the ModuleInfo item and some custom formatting. - Write-Output $installPlan - } else { - Write-Progress -Id 1 -Activity 'Install-ModuleFast' -Status "Installing: $($installPlan.count) Modules" -PercentComplete 50 - - $installHelperParams = @{ - ModuleToInstall = $installPlan - Destination = $Destination - CancellationToken = $cancelSource.Token - HttpClient = $httpClient - Update = $Update -or $PSCmdlet.ParameterSetName -eq 'ModuleFastInfo' - ThrottleLimit = $ThrottleLimit - } - $installedModules = Install-ModuleFastHelper @installHelperParams - Write-Progress -Id 1 -Activity 'Install-ModuleFast' -Completed - Write-Verbose "`u{2705} All required modules installed! Exiting." - if ($PassThru) { - Write-Output $installedModules - } - } - - if ($CI) { - #FIXME: If a package was already installed, it doesn't show up in this lockfile. - Write-Verbose "Writing lockfile to $CILockFilePath" - [Dictionary[string, string]]$lockFile = @{} - $installPlan - | ForEach-Object { - $lockFile.Add($PSItem.Name, $PSItem.ModuleVersion) - } - - $lockFile - | ConvertTo-Json -Depth 2 - | Out-File -FilePath $CILockFilePath -Encoding UTF8 - } - } finally { - $cancelSource.Dispose() - } - } -} - -function New-ModuleFastClient { - param( - [PSCredential]$Credential, - [int]$Timeout = 30 - ) - Write-Debug 'Creating new ModuleFast HTTP Client. This should only happen once!' - $ErrorActionPreference = 'Stop' - #SocketsHttpHandler is the modern .NET 5+ default handler for HttpClient. - - $httpHandler = [SocketsHttpHandler]@{ - #The max connections are only in case we end up using HTTP/1.1 instead of HTTP/2 for whatever reason. HTTP/2 will only use one connection (but multiple streams) per the spec unless EnableMultipleHttp2Connections is specified - MaxConnectionsPerServer = 10 - #Reduce the amount of round trip confirmations by setting window size to 64MB. ModuleFast should primarily be used on reliable fast connections. Dynamic scaling will reduce this if needed. - InitialHttp2StreamWindowSize = 16777216 - AutomaticDecompression = 'All' - } - - $httpClient = [HttpClient]::new($httpHandler) - $httpClient.BaseAddress = $Source - #When in parallel some operations may take a significant amount of time to return - $httpClient.Timeout = [TimeSpan]::FromSeconds($Timeout) - - #If a credential was provided, use it as a basic auth credential - if ($Credential) { - $httpClient.DefaultRequestHeaders.Authorization = ConvertTo-AuthenticationHeaderValue $Credential - } - - #This user agent is important, it indicates to pwsh.gallery that we want dependency-only metadata - #TODO: Do this with a custom header instead - $userHeaderAdded = $httpClient.DefaultRequestHeaders.UserAgent.TryParseAdd('ModuleFast (github.com/JustinGrote/ModuleFast)') - if (-not $userHeaderAdded) { - throw 'Failed to add User-Agent header to HttpClient. This is a bug' - } - - #This will multiplex all queries over a single connection, minimizing TLS setup overhead - #Should also support HTTP/3 on newest PS versions - $httpClient.DefaultVersionPolicy = [HttpVersionPolicy]::RequestVersionOrHigher - #This should enable HTTP/3 on Win11 22H2+ (or linux with http3 library) and PS 7.2+ - [void][AppContext]::SetSwitch('System.Net.SocketsHttpHandler.Http3Support', $true) - return $httpClient -} - -function Get-ModuleFastPlan { - <# - .NOTES - THIS COMMAND IS DEPRECATED AND WILL NOT RECEIVE PARAMETER UPDATES. Please use Install-ModuleFast -Plan instead. - #> - [CmdletBinding()] - [OutputType([ModuleFastInfo[]])] - param( - #The module(s) to install. This can be a string, a ModuleSpecification, a hashtable with nuget version style (e.g. @{Name='test';Version='1.0'}), a hashtable with ModuleSpecification style (e.g. @{Name='test';RequiredVersion='1.0'}), - [Alias('Name')] - [Parameter(Position = 0, Mandatory, ValueFromPipeline)][ModuleFastSpec[]]$Specification, - #The repository to scan for modules. TODO: Multi-repo support - [string]$Source = 'https://pwsh.gallery/index.json', - #Whether to include prerelease modules in the request - [Switch]$Prerelease, - #By default we use in-place modules if they satisfy the version requirements. This switch will force a search for all latest modules - [Switch]$Update, - [PSCredential]$Credential, - [int]$Timeout = 30, - [HttpClient]$HttpClient = $(New-ModuleFastClient -Credential $Credential -Timeout $Timeout), - [int]$ParentProgress, - [string]$Destination, - [switch]$DestinationOnly, - [CancellationToken]$CancellationToken, - [switch]$StrictSemVer - ) - - BEGIN { - trap {$PSCmdlet.ThrowTerminatingError($PSItem)} - - $ErrorActionPreference = 'Stop' - [HashSet[ModuleFastSpec]]$modulesToResolve = @() - - #We use this token to cancel the HTTP requests if the user hits ctrl-C without having to dispose of the HttpClient. - #We get a child so that a cancellation here does not affect any upstream commands - $cancelTokenSource = $CancellationToken ? [CancellationTokenSource]::CreateLinkedTokenSource($CancellationToken) : [CancellationTokenSource]::new() - $CancellationToken = $cancelTokenSource.Token - - #We pass this splat to all our HTTP requests to cut down on boilerplate - $httpContext = @{ - HttpClient = $httpClient - CancellationToken = $CancellationToken - } - } - PROCESS { - trap {$PSCmdlet.ThrowTerminatingError($PSItem)} - - foreach ($spec in $Specification) { - if (-not $ModulesToResolve.Add($spec)) { - Write-Warning "$spec was specified twice, skipping duplicate" - } - } - } - END { - trap {$PSCmdlet.ThrowTerminatingError($PSItem)} - - try { - # A deduplicated list of modules to install - [HashSet[ModuleFastInfo]]$modulesToInstall = @{} - - # We use this as a fast lookup table for the context of the request - [Dictionary[Task, ModuleFastSpec]]$taskSpecMap = @{} - - #We use this to track the tasks that are currently running - #We dont need this to be ConcurrentList because we only manipulate it in the "main" runspace. - [List[Task]]$currentTasks = @() - - #This is used to track the highest candidate if -Update was specified to force a remote lookup. If the candidate is still the most valid after remote lookup we can skip it without hitting disk to read the manifest again. - [Dictionary[ModuleFastSpec, ModuleFastInfo]]$bestLocalCandidate = @{} - - foreach ($moduleSpec in $ModulesToResolve) { - Write-Verbose "${moduleSpec}: Evaluating Module Specification" - $findLocalParams = @{ - Update = $Update - BestCandidate = ([ref]$bestLocalCandidate) - } - if ($DestinationOnly) { $findLocalParams.ModulePaths = $Destination } - - [ModuleFastInfo]$localMatch = Find-LocalModule @findLocalParams $moduleSpec - if ($localMatch) { - Write-Debug "${localMatch}: 🎯 FOUND satisfying version $($localMatch.ModuleVersion) at $($localMatch.Location). Skipping remote search." - #TODO: Capture this somewhere that we can use it to report in the deploy plan - continue - } - - #If we get this far, we didn't find a manifest in this module path - Write-Debug "${moduleSpec}: 🔍 No installed versions matched the spec. Will check remotely." - - $task = Get-ModuleInfoAsync @httpContext -Endpoint $Source -Name $moduleSpec.Name - $taskSpecMap[$task] = $moduleSpec - $currentTasks.Add($task) - } - - [int]$tasksCompleteCount = 1 - [int]$resolveTaskCount = $currentTasks.Count -as [Int] - do { - #The timeout here allow ctrl-C to continue working in PowerShell - #-1 is returned by WaitAny if we hit the timeout before any tasks completed - $noTasksYetCompleted = -1 - [int]$thisTaskIndex = [Task]::WaitAny($currentTasks, 500) - if ($thisTaskIndex -eq $noTasksYetCompleted) { continue } - - #The Plan whitespace is intentional so that it lines up with install progress using the compact format - Write-Progress -Id 1 -Activity 'Install-ModuleFast' -Status "Plan: Resolving $tasksCompleteCount/$resolveTaskCount Module Dependencies" -PercentComplete ((($tasksCompleteCount / $resolveTaskCount) * 50) + 1) - - #TODO: This only indicates headers were received, content may still be downloading and we dont want to block on that. - #For now the content is small but this could be faster if we have another inner loop that WaitAny's on content - #TODO: Perform a HEAD query to see if something has changed - - $completedTask = $currentTasks[$thisTaskIndex] - [ModuleFastSpec]$currentModuleSpec = $taskSpecMap[$completedTask] - if (-not $currentModuleSpec) { - throw 'Failed to find Module Specification for completed task. This is a bug.' - } - - if ($currentModuleSpec.Guid -ne [Guid]::Empty) { - Write-Warning "${currentModuleSpec}: A GUID constraint was found in the module spec. ModuleSpec will currently only verify GUIDs after the module has been installed, so a plan may not be accurate. It is not recommended to match modules by GUID in ModuleFast, but instead verify package signatures for full package authenticity." - } - - Write-Debug "${currentModuleSpec}: Processing Response" - # We use GetAwaiter so we get proper error messages back, as things such as network errors might occur here. - try { - $response = $completedTask.GetAwaiter().GetResult() - | ConvertFrom-Json - Write-Debug "${currentModuleSpec}: Received Response with $($response.Count) pages" - } catch { - $taskException = $PSItem.Exception.InnerException - #TODO: Rewrite this as a handle filter - if ($taskException -isnot [HttpRequestException]) { throw } - [HttpRequestException]$err = $taskException - if ($err.StatusCode -eq [HttpStatusCode]::NotFound) { - throw [InvalidOperationException]"${currentModuleSpec}: module was not found in the $Source repository. Check the spelling and try again." - } - - #All other cases - $PSItem.ErrorDetails = "${currentModuleSpec}: Failed to fetch module $currentModuleSpec from $Source. Error: $PSItem" - throw $PSItem - } - - if (-not $response.count) { - throw [InvalidDataException]"${currentModuleSpec}: invalid result received from $Source. This is probably a bug. Content: $response" - } - - #If what we are looking for exists in the response, we can stop looking - #TODO: Type the responses and check on the type, not the existence of a property. - - #TODO: This needs to be moved to a function so it isn't duplicated down in the "else" section below - $pageLeaves = $response.items.items - $pageLeaves | ForEach-Object { - if ($PSItem.packageContent -and -not $PSItem.catalogEntry.packagecontent) { - $PSItem.catalogEntry - | Add-Member -NotePropertyName 'PackageContent' -NotePropertyValue $PSItem.packageContent - } - } - - $entries = $pageLeaves.catalogEntry - - #Get the highest version that satisfies the requirement in the inlined index, if possible - $selectedEntry = if ($entries) { - #Sanity Check for Modules - if ('ItemType:Script' -in $entries[0].tags) { - throw [NotImplementedException]"${currentModuleSpec}: Script installations are currently not supported." - } - - [SortedSet[NuGetVersion]]$inlinedVersions = $entries.version - - foreach ($candidate in $inlinedVersions.Reverse()) { - #Skip Prereleases unless explicitly requested - if (($candidate.IsPrerelease -or $candidate.HasMetadata) -and -not ($currentModuleSpec.PreRelease -or $Prerelease)) { - Write-Debug "${moduleSpec}: skipping candidate $candidate because it is a prerelease and prerelease was not specified either with the -Prerelease parameter, by specifying a prerelease version in the spec, or adding a ! on the module name spec to indicate prerelease is acceptable." - continue - } - - if ($currentModuleSpec.SatisfiedBy($candidate, $StrictSemVer)) { - Write-Debug "${ModuleSpec}: Found satisfying version $candidate in the inlined index." - $matchingEntry = $entries | Where-Object version -EQ $candidate - if ($matchingEntry.count -gt 1) { throw 'Multiple matching Entries found for a specific version. This is a bug and should not happen' } - $matchingEntry - break - } - } - } - - if ($selectedEntry.count -gt 1) { throw 'Multiple Entries Selected. This is a bug.' } - #Search additional pages if we didn't find it in the inlined ones - $selectedEntry ??= $( - Write-Debug "${currentModuleSpec}: not found in inlined index. Determining appropriate page(s) to query" - - #If not inlined, we need to find what page(s) might have the candidate info we are looking for, starting with the highest numbered page first - - $pages = $response.items - | Where-Object { -not $PSItem.items } #Get non-inlined pages - | Where-Object { - [VersionRange]$pageRange = [VersionRange]::new($PSItem.Lower, $true, $PSItem.Upper, $true, $null, $null) - return $currentModuleSpec.Overlap($pageRange) - } - | Sort-Object -Descending { [NuGetVersion]$PSItem.Upper } - - if (-not $pages) { - throw [InvalidOperationException]"${currentModuleSpec}: a matching module was not found in the $Source repository that satisfies the requested version constraints. You may need to specify -PreRelease or adjust your version constraints." - } - - Write-Debug "${currentModuleSpec}: Found $(@($pages).Count) additional pages that might match the query: $($pages.'@id' -join ',')" - - #TODO: This is relatively slow and blocking, but we would need complicated logic to process it in the main task handler loop. - #I really should make a pipeline that breaks off tasks based on the type of the response. - #This should be a relatively rare query that only happens when the latest package isn't being resolved. - - #Start with the highest potentially matching page and work our way down until we find a match. - foreach ($page in $pages) { - $response = (Get-ModuleInfoAsync @httpContext -Uri $page.'@id').GetAwaiter().GetResult() | ConvertFrom-Json - - $pageLeaves = $response.items | ForEach-Object { - if ($PSItem.packageContent -and -not $PSItem.catalogEntry.packagecontent) { - $PSItem.catalogEntry - | Add-Member -NotePropertyName 'PackageContent' -NotePropertyValue $PSItem.packageContent - } - $PSItem - } - - $entries = $pageLeaves.catalogEntry - - #TODO: Dedupe as a function with above - if ($entries) { - [SortedSet[NuGetVersion]]$pageVersions = $entries.version - - foreach ($candidate in $pageVersions.Reverse()) { - #Skip Prereleases unless explicitly requested - if (($candidate.IsPrerelease -or $candidate.HasMetadata) -and -not ($currentModuleSpec.PreRelease -or $Prerelease)) { - Write-Debug "Skipping candidate $candidate because it is a prerelease and prerelease was not specified either with the -Prerelease parameter or with a ! on the module name." - continue - } - - if ($currentModuleSpec.SatisfiedBy($candidate, $StrictSemVer)) { - Write-Debug "${currentModuleSpec}: Found satisfying version $candidate in the additional pages." - $matchingEntry = $entries | Where-Object version -EQ $candidate - if (-not $matchingEntry) { throw 'Multiple matching Entries found for a specific version. This is a bug and should not happen' } - $matchingEntry - break - } - } - } - - #Candidate found, no need to process additional pages - if ($matchingEntry) { break } - } - ) - - if (-not $selectedEntry) { - throw [InvalidOperationException]"${currentModuleSpec}: a matching module was not found in the $Source repository that satisfies the version constraints. You may need to specify -PreRelease or adjust your version constraints." - } - if (-not $selectedEntry.PackageContent) { throw "No package location found for $($selectedEntry.PackageContent). This should never happen and is a bug" } - - [ModuleFastInfo]$selectedModule = [ModuleFastInfo]::new( - $selectedEntry.id, - $selectedEntry.version, - $selectedEntry.PackageContent - ) - if ($moduleSpec.Guid -and $moduleSpec.Guid -ne [Guid]::Empty) { - $selectedModule.Guid = $moduleSpec.Guid - } - - #If -Update was specified, we need to re-check that none of the selected modules are already installed. - #TODO: Persist state of the local modules found to this point so we don't have to recheck. - if ($Update -and $bestLocalCandidate[$currentModuleSpec].ModuleVersion -eq $selectedModule.ModuleVersion) { - Write-Debug "${selectedModule}: ✅ -Update was specified and the best remote candidate matches what is locally installed, so we can skip this module." - #TODO: Fix the flow so this isn't stated twice - [void]$taskSpecMap.Remove($completedTask) - [void]$currentTasks.Remove($completedTask) - $tasksCompleteCount++ - continue - } - - #Check if we have already processed this item and move on if we have - if (-not $modulesToInstall.Add($selectedModule)) { - Write-Debug "$selectedModule already exists in the install plan. Skipping..." - #TODO: Fix the flow so this isn't stated twice - [void]$taskSpecMap.Remove($completedTask) - [void]$currentTasks.Remove($completedTask) - $tasksCompleteCount++ - continue - } - - Write-Verbose "${selectedModule}: Added to install plan" - - # HACK: Pwsh doesn't care about target framework as of today so we can skip that evaluation - # TODO: Should it? Should we check for the target framework and only install if it matches? - $dependencyInfo = $selectedEntry.dependencyGroups.dependencies - - #Determine dependencies and add them to the pending tasks - if ($dependencyInfo) { - # HACK: I should be using the Id provided by the server, for now I'm just guessing because - # I need to add it to the ComparableModuleSpec class - [List[ModuleFastSpec]]$dependencies = $dependencyInfo | ForEach-Object { - # Handle rare cases where range is not specified in the dependency - [VersionRange]$range = [string]::IsNullOrWhiteSpace($PSItem.range) ? - [VersionRange]::new() : - [VersionRange]::Parse($PSItem.range) - - [ModuleFastSpec]::new($PSItem.id, $range) - } - Write-Debug "${currentModuleSpec}: has $($dependencies.count) additional dependencies: $($dependencies -join ', ')" - - # TODO: Where loop filter maybe - [ModuleFastSpec[]]$dependenciesToResolve = $dependencies | Where-Object { - $dependency = $PSItem - # TODO: This dependency resolution logic should be a separate function - # Maybe ModulesToInstall should be nested/grouped by Module Name then version to speed this up, as it currently - # enumerates every time which shouldn't be a big deal for small dependency trees but might be a - # meaninful performance difference on a whole-system upgrade. - [HashSet[string]]$moduleNames = $modulesToInstall.Name - if ($dependency.Name -notin $ModuleNames) { - Write-Debug "$($dependency.Name): No modules with this name currently exist in the install plan. Resolving dependency..." - return $true - } - - $modulesToInstall - | Where-Object Name -EQ $dependency.Name - | Sort-Object ModuleVersion -Descending - | ForEach-Object { - if ($dependency.SatisfiedBy($PSItem.ModuleVersion, $StrictSemVer)) { - Write-Debug "Dependency $dependency satisfied by existing planned install item $PSItem" - return $false - } - } - - Write-Debug "Dependency $($dependency.Name) is not satisfied by any existing planned install items. Resolving dependency..." - return $true - } - - if (-not $dependenciesToResolve) { - Write-Debug "$moduleSpec has no remaining dependencies that need resolving" - continue - } - - Write-Debug "Fetching info on remaining $($dependenciesToResolve.count) dependencies" - - # We do this here rather than populate modulesToResolve because the tasks wont start until all the existing tasks complete - # TODO: Figure out a way to dedupe this logic maybe recursively but I guess a function would be fine too - foreach ($dependencySpec in $dependenciesToResolve) { - $findLocalParams = @{ - Update = $Update - BestCandidate = ([ref]$bestLocalCandidate) - } - if ($DestinationOnly) { $findLocalParams.ModulePaths = $Destination } - - [ModuleFastInfo]$localMatch = Find-LocalModule @findLocalParams $dependencySpec - if ($localMatch) { - Write-Debug "FOUND local module $($localMatch.Name) $($localMatch.ModuleVersion) at $($localMatch.Location.AbsolutePath) that satisfies $moduleSpec. Skipping..." - #TODO: Capture this somewhere that we can use it to report in the deploy plan - continue - } else { - Write-Debug "No local modules that satisfies dependency $dependencySpec. Checking Remote..." - } - - Write-Debug "${currentModuleSpec}: Fetching dependency $dependencySpec" - #TODO: Do a direct version lookup if the dependency is a required version - $task = Get-ModuleInfoAsync @httpContext -Endpoint $Source -Name $dependencySpec.Name - $taskSpecMap[$task] = $dependencySpec - #Used to track progress as tasks can get removed - $resolveTaskCount++ - - $currentTasks.Add($task) - } - } - - #Putting .NET methods in a try/catch makes errors in them terminating - try { - [void]$taskSpecMap.Remove($completedTask) - [void]$currentTasks.Remove($completedTask) - $tasksCompleteCount++ - } catch { - throw - } - } while ($currentTasks.count -gt 0) - - if ($modulesToInstall) { return $modulesToInstall } - } finally { - #Cancel any outstanding tasks if unexpected error occurs - $cancelTokenSource.Dispose() +# Register type accelerators so [ModuleFastSpec], [ModuleFastInfo], etc. work without namespace +$accelerators = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators') +foreach ($pair in @{ + 'ModuleFastSpec' = [ModuleFast.ModuleFastSpec] + 'ModuleFastInfo' = [ModuleFast.ModuleFastInfo] + 'SpecFileType' = [ModuleFast.SpecFileType] + 'InstallScope' = [ModuleFast.InstallScope] +}.GetEnumerator()) { + if (-not $accelerators::Get.ContainsKey($pair.Key)) { + $accelerators::Add($pair.Key, $pair.Value) } - } -} - -function Clear-ModuleFastCache { - <# - .SYNOPSIS - Clears the ModuleFast HTTP Cache. This is useful if you are expecting a newer version of a module to be available. - #> - Write-Debug "Flushing ModuleFast Request Cache" - $SCRIPT:RequestCache.Dispose() - $SCRIPT:RequestCache = [MemoryCache]::new('PowerShell-ModuleFast-RequestCache') } - -#endregion Public - -#region Private - -function Install-ModuleFastHelper { - [CmdletBinding()] - param( - [Parameter(Mandatory)][ModuleFastInfo[]]$ModuleToInstall, - [string]$Destination, - [Parameter(Mandatory)][CancellationToken]$CancellationToken, - [HttpClient]$HttpClient, - [switch]$Update, - [int]$ThrottleLimit, - [int]$Timeout = 30 - ) - BEGIN { - #We use this token to cancel the HTTP requests if the user hits ctrl-C without having to dispose of the HttpClient. - #We get a child so that a cancellation here does not affect any upstream commands - $cancelTokenSource = $CancellationToken ? [CancellationTokenSource]::CreateLinkedTokenSource($CancellationToken) : [CancellationTokenSource]::new() - $CancellationToken = $cancelTokenSource.Token - } - END { - $ErrorActionPreference = 'Stop' - - try { - #Used to keep track of context with Tasks, because we dont have "await" style syntax like C# - [Dictionary[Task, hashtable]]$taskMap = @{} - [List[Task[Stream]]]$streamTasks = foreach ($module in $ModuleToInstall) { - $installPath = Join-Path $Destination $module.Name (Resolve-FolderVersion $module.ModuleVersion) - #TODO: Do a get-localmodule check here - $installIndicatorPath = Join-Path $installPath '.incomplete' - if (Test-Path $installIndicatorPath) { - Write-Warning "${module}: Incomplete installation found at $installPath. Will delete and retry." - Remove-Item $installPath -Recurse -Force - } - - if (Test-Path $installPath) { - $existingManifestPath = try { - Resolve-Path (Join-Path $installPath "$($module.Name).psd1") -ErrorAction Stop - } catch [ActionPreferenceStopException] { - throw "${module}: Existing module folder found at $installPath but the manifest could not be found. This is likely a corrupted or missing module and should be fixed manually." - } - - #TODO: Dedupe all import-powershelldatafile operations to a function ideally - $existingModuleMetadata = Import-ModuleManifest $existingManifestPath - $existingVersion = [NugetVersion]::new( - $existingModuleMetadata.ModuleVersion, - $existingModuleMetadata.privatedata.psdata.prerelease - ) - - #Do a prerelease evaluation - if ($module.ModuleVersion -eq $existingVersion) { - if ($Update) { - Write-Debug "${module}: Existing module found at $installPath and its version $existingVersion is the same as the requested version. -Update was specified so we are assuming that the discovered online version is the same as the local version and skipping this module installation." - continue - } else { - throw [NotImplementedException]"${module}: Existing module found at $installPath and its version $existingVersion is the same as the requested version. This is probably a bug because it should have been detected by localmodule detection. Use -Update to override..." - } - } - if ($module.ModuleVersion -lt $existingVersion) { - #TODO: Add force to override - throw [NotSupportedException]"${module}: Existing module found at $installPath and its version $existingVersion is newer than the requested prerelease version $($module.ModuleVersion). If you wish to continue, please remove the existing module folder or modify your specification and try again." - } else { - Write-Warning "${module}: Planned version $($module.ModuleVersion) is newer than existing prerelease version $existingVersion so we will overwrite." - Remove-Item $installPath -Force -Recurse - } - } - - Write-Verbose "${module}: Downloading from $($module.Location)" - if (-not $module.Location) { - throw "${module}: No Download Link found. This is a bug" - } - - $streamTask = $httpClient.GetStreamAsync($module.Location, $CancellationToken) - $context = @{ - Module = $module - InstallPath = $installPath - } - $taskMap.Add($streamTask, $context) - $streamTask - } - - - [List[Job2]]$installJobs = while ($streamTasks.count -gt 0) { - $noTasksYetCompleted = -1 - [int]$thisTaskIndex = [Task]::WaitAny($streamTasks, 500) - if ($thisTaskIndex -eq $noTasksYetCompleted) { continue } - $thisTask = $streamTasks[$thisTaskIndex] - $stream = $thisTask.GetAwaiter().GetResult() - $context = $taskMap[$thisTask] - $context.fetchStream = $stream - $streamTasks.RemoveAt($thisTaskIndex) - - # This is a sync process and we want to do it in parallel, hence the threadjob - Write-Verbose "$($context.Module): Extracting to $($context.installPath)" - $installJob = Start-ThreadJob -ThrottleLimit $ThrottleLimit { - param( - [ValidateNotNullOrEmpty()]$stream = $USING:stream, - [ValidateNotNullOrEmpty()]$context = $USING:context - ) - process { - try { - $installPath = $context.InstallPath - $installIndicatorPath = Join-Path $installPath '.incomplete' - - if (Test-Path $installIndicatorPath) { - #FIXME: Output inside a threadjob is not surfaced to the user. - Write-Warning "$($context.Module): Incomplete installation found at $installPath. Will delete and retry." - Remove-Item $installPath -Recurse -Force - } - - if (-not (Test-Path $context.InstallPath)) { - New-Item -Path $context.InstallPath -ItemType Directory -Force | Out-Null - } - - New-Item -ItemType File -Path $installIndicatorPath -Force | Out-Null - - #We are going to extract these straight out of memory, so we don't need to write the nupkg to disk - $zip = [IO.Compression.ZipArchive]::new($stream, 'Read') - [IO.Compression.ZipFileExtensions]::ExtractToDirectory($zip, $installPath) - - $manifestPath = Join-Path $installPath "$($context.Module.Name).psd1" - - # Try to read the manifest with a streamreader just to ModuleVersion. - # This makes a glaring but reasonable assumption that the moduleVersion is not dynamic and has no newlines. - # Much more performant than a full parse, as it will stop as soon as it hits moduleversion, perhaps at the - # expense of more iops due to the readline vs reading the entire file at once - $reader = [IO.StreamReader]::new($manifestPath) - [Version]$moduleManifestVersion = $null - try { - while ($null -ne ($line = $reader.ReadLine())) { - if ($line -match '\s*ModuleVersion\s*=\s*[''"](?.+?)[''"]') { - $moduleManifestVersion = $matches['version'] - break - } - } - } finally { - $reader.Close() - } - - # Resolves an edge case where nuget packages are normalized in some package manages from 3.2.1.0 to 3.2.1 - if (-not $moduleManifestVersion) { - Write-Warning "$($context.Module): Could not detect the module manifest version. This module may not install properly if it has trailing zeros in the version" - } else { - $installPathRoot = Split-Path $installPath - $originalModuleVersion = Split-Path $installPath -Leaf - if ($originalModuleVersion -ne $moduleManifestVersion) - { - Write-Debug "$($context.Module): Module Manifest Version $moduleManifestVersion differs from package version $originalModuleVersion, moving..." - - $newInstallPath = Join-Path $installPathRoot $moduleManifestVersion - [System.IO.Directory]::Move($installPath, $newInstallPath) - - $installPath = $newInstallPath - $context.InstallPath = $installPath - $context.Module.ModuleVersion = [string]$moduleManifestVersion #Some System.Version don't cast right - $originalModuleVersion > (Join-Path $installPath '.originalModuleVersion') - $installIndicatorPath = Join-Path $installPath '.incomplete' - } else { - Write-Debug "$($context.Module): Module Manifest version matches the expected version" - } - } - - if ($context.Module.Guid -and $context.Module.Guid -ne [Guid]::Empty) { - Write-Debug "$($context.Module): GUID was specified in Module. Verifying manifest" - $manifestPath = Join-Path $installPath "$($context.Module.Name).psd1" - #FIXME: This should be using Import-ModuleManifest but it needs to be brought in via the ThreadJob context. This will fail if the module has a dynamic manifest. - $manifest = Import-PowerShellDataFile $manifestPath - if ($manifest.Guid -ne $context.Module.Guid) { - Remove-Item $installPath -Force -Recurse - throw [InvalidOperationException]"$($context.Module): The installed package GUID does not match what was in the Module Spec. Expected $($context.Module.Guid) but found $($manifest.Guid) in $($manifestPath). Deleting this module, please check that your GUID specification is correct, or otherwise investigate why the GUID is different." - } - } - - #FIXME: Output inside a threadjob is not surfaced to the user. - Write-Debug "Cleanup Nuget Files in $installPath" - if (-not $installPath) { throw 'ModuleDestination was not set. This is a bug, report it' } - Get-ChildItem -Path $installPath | Where-Object { - $_.Name -in '_rels', 'package', '[Content_Types].xml' -or - $_.Name.EndsWith('.nuspec') - } | Remove-Item -Force -Recurse - - Remove-Item $installIndicatorPath -Force - return $context - } finally { - if ($zip) {$zip.Dispose()} - if ($stream) {$stream.Dispose()} - } - } - } - $installJob - } - - $installed = 0 - $installedModules = while ($installJobs.count -gt 0) { - $ErrorActionPreference = 'Stop' - $completedJob = $installJobs | Wait-Job -Any - $completedJobContext = $completedJob | Receive-Job -Wait -AutoRemoveJob - if (-not $installJobs.Remove($completedJob)) { throw 'Could not remove completed job from list. This is a bug, report it' } - $installed++ - Write-Verbose "$($completedJobContext.Module): Installed to $($completedJobContext.InstallPath)" - Write-Progress -Id 1 -Activity 'Install-ModuleFast' -Status "Install: $installed/$($ModuleToInstall.count) Modules" -PercentComplete ((($installed / $ModuleToInstall.count) * 50) + 50) - $completedJobContext.Module.Location = $completedJobContext.InstallPath - #Output the module for potential future passthru - $completedJobContext.Module - } - - if ($PassThru) { - return $installedModules - } - - } finally { - $cancelTokenSource.Dispose() - if ($installJobs) { - try { - $installJobs | Remove-Job -Force -ErrorAction SilentlyContinue - } catch { - #Suppress this error because it is likely that the job was already removed - if ($PSItem -notlike '*because it is a child job*') {throw} - } - } +$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = { + $accelerators = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators') + 'ModuleFastSpec','ModuleFastInfo','SpecFileType','InstallScope' | ForEach-Object { + $accelerators::Remove($_) } - } } -function Import-ModuleManifest { - <# - .SYNOPSIS - Imports a module manifest from a path, and can handle some limited dynamic module manifest formats. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory, ValueFromPipeline)][string]$Path - ) - - try { - Import-PowerShellDataFile $Path -ErrorAction Stop - } catch [InvalidOperationException] { - if ($PSItem.Exception.Message -notlike '*Cannot generate a PowerShell object for a ScriptBlock evaluating dynamic expressions*') {throw} - - Write-Debug "$Path is a Manifest with dynamic expressions. Attempting to safe evaluate..." - #Inspiration from: https://github.com/PowerShell/PSResourceGet/blob/0a1836a4088ab0f4f13a4638fa8cd0f571c24140/src/code/Utils.cs#L1219 - $manifest = [ScriptBlock]::Create((Get-Content $Path -Raw)) - - $manifest.CheckRestrictedLanguage( - [list[string]]::new(), - [list[string]]@('PSEdition','PSScriptRoot'), - $true - ) - return $manifest.InvokeReturnAsIs(); - } -} - -function ConvertFrom-PSDepend { - [OutputType([ModuleFastSpec[]])] - param( - [hashtable]$PSDependManifest - ) - - $initialSpec = [ordered]@{} - - if ($PSDependManifest.ContainsKey('PSDependOptions')) { - Write-Debug 'PSDepend Parse: PSDependOptions detected. Removing...' - $options = $PSDependManifest['PSDependOptions'] - if ($options.DependencyType) { - throw [NotSupportedException]"PSDepend Parse: Top-Level DependencyType in PSDependOptions is not currently supported." - } - if ($options.Target) { - Write-Warning "PSDepend Parse: Target in PSDependOptions is not currently supported and will be ignored. Ensure you have -Destination $($options.Target) specified on the Install-ModuleFast command." - } - $PSDependManifest.Remove('PSDependOptions') - } - - foreach ($key in $PSDependManifest.Keys) { - $value = $PSDependManifest[$key] - - if ($key -isnot [string]) { - throw [InvalidDataException]"PSDepend Parse: Manifest is invalid. Keys must be strings. Found $key $($key.GetType().FullName)" - } - - if ($key -like '*/*') { - Write-Debug "PSDepend Parse: Skipping Unsupported GitHub module $key" - continue - } - - if ($key -match '^(.+)::(.+)$') { - if ($matches[0] -ne 'PSGalleryModule') { - Write-Debug "PSDepend Parse: Skipping $key because its extended type is not PSGalleryModule" - continue - } else { - Write-Debug "PSDepend Parse: Adding $key $value)" - $initialSpec[$matches[1]] = $value - continue - } - } elseif ($value -is [string]) { - #If the key doesn't have any special formats and the value is a string, we can assume it is a direct "shorthand" specification - $initialSpec[$key] = $value - continue - } - - #At this point there should only be PSDepend "Extended" Syntax objects - if ($value -isnot [hashtable]) { - throw [NotSupportedException]'PSDepend Parse: Value target must be a string or hashtable' - } - - if ($value.DependencyType -ne 'PSGalleryModule') { - Write-Debug "PSDepend Parse: Skipping $key because its extended DependencyType is not PSGalleryModule" - continue - } - - if ($value.Parameters.Repository) { - Write-Warning "PSDepend Parse: Repository specification detected for $key. This is not currently supported and will use the default Source for now." - } - - $version = $value.Version ?? 'latest' - - #TODO: Repository support - if (-not $value.Name) { - Write-Debug 'PSDepend Parse: Skipping $key because no Name property was specified' - } - - if ($value.Parameters.AllowPrerelease) { - Write-Debug "PSDepend Parse: Prerelease detected for $key" - $value.Name = "!$($value.Name)" - } - - Write-Debug "PSDepend Parse: Adding $key extended module name $($value.Name) $version" - $initialSpec[$value.Name] = $version - } - - foreach ($entry in $initialspec.GetEnumerator()) { - if ($entry.Value -eq 'latest') { - [ModuleFastSpec]::new($entry.Key) - } else { - [ModuleFastSpec]::new($entry.Key, $entry.Value) - } - } -} - -function ConvertFrom-PSResourceGet { - [OutputType([ModuleFastSpec[]])] - param( - [hashtable]$PSDependManifest - ) - - $initialSpec = [ordered]@{} - foreach ($key in $PSDependManifest.Keys) { - $value = $PSDependManifest[$key] - - if ($key -isnot [string]) { - throw [InvalidDataException]"PSResourceGet Parse: Manifest is invalid. Keys must be strings. Found $key $($key.GetType().FullName)" - } - - if ($value -is [string]) { - $initialSpec[$key] = $value - continue - } - - #At this point there should only be PSDepend "Extended" Syntax objects - if ($value -isnot [hashtable]) {throw [NotSupportedException]'PSResourceGet Parse: Value target must be a string or hashtable'} - - $version = $value.Version ?? 'latest' - - if ($value.prerelease) { - Write-Debug "PSResourceGet Parse: Prerelease detected for $key" - $key = "!$key" - } - - if ($value.Repository) { - Write-Warning "PSResourceGet Parse: Repository specification detected for $key. This is not currently supported and will use the default Source for now." - } - - Write-Debug "PSResourceGet Parse: Adding $key extended module name $key $version" - $initialSpec[$key] = $version - } - - foreach ($entry in $initialspec.GetEnumerator()) { - if ($entry.Value -eq 'latest') { - [ModuleFastSpec]::new($entry.Key) - } else { - $version = $entry.Value - - #HACK: This handles a PSResourceGet/RequiresModule quirk where '1.0.5' is meant to be a specific version, not a minimum version which is what the NuGet version spec defines it as. - # https://learn.microsoft.com/en-us/nuget/concepts/package-versioning?tabs=semver20sort - if ($version.StartsWith('[') -or $version.StartsWith('(') -or $version.Contains('*')) { - $version = [VersionRange]::Parse($entry.Value) - } - - [ModuleFastSpec]::new($entry.Key, $version) - } - } -} - -#endregion Private - -#region Classes - -enum SpecFileType { - AutoDetect - ModuleFast - PSResourceGet #Note: RequiredModules seems to be semantically close enough to PSResourceGet to use the same parser - PSDepend -} - -#This is a module construction helper to create "getters" in classes. The getters must be defined as a static hidden class prefixed with Get_ (case sensitive) and take a single parameter of the PSObject type that will be an instance of the class object for you to act on. Place this in your class constructor to automatically add the getters to the class. -function Add-Getters ([Parameter(Mandatory, ValueFromPipeline)][Type]$Type) { - $Type.GetMethods([BindingFlags]::Static -bor [BindingFlags]::Public) - | Where-Object name -CLike 'Get_*' - | Where-Object { $_.GetCustomAttributes([HiddenAttribute]) } - | Where-Object { - $params = $_.GetParameters() - $params.count -eq 1 -and $params[0].ParameterType -eq [PSObject] - } - | ForEach-Object { - Update-TypeData -TypeName $Type.FullName -MemberType CodeProperty -MemberName $($_.Name -replace 'Get_', '') -Value $PSItem -Force - } -} - -#Information about a module, whether local or remote -[NoRunspaceAffinity()] -class ModuleFastInfo: IComparable { - [string]$Name - #Sometimes the module version is not the same as the folder version, such as in the case of prerelease versions - [NuGetVersion]$ModuleVersion - #Path to the module, either local or remote - [uri]$Location - #TODO: This should be a getter - [boolean]$IsLocal - [Guid]$Guid = [Guid]::Empty - - ModuleFastInfo([string]$Name, [NuGetVersion]$ModuleVersion, [Uri]$Location) { - $this.Name = $Name - $this.ModuleVersion = $ModuleVersion - $this.Location = $Location - $this.IsLocal = $Location.IsFile - } - - static hidden [Version]Get_Prerelease([bool]$i) { - return $i.ModuleVersion.IsPrerelease -or $i.ModuleVersion.HasMetadata - } - - #region ImplicitBehaviors - # Implement an op_implicit convert to modulespecification - static [ModuleSpecification]op_Implicit([ModuleFastInfo]$moduleFastInfo) { - return [ModuleSpecification]::new(@{ - ModuleName = $moduleFastInfo.Name - RequiredVersion = $moduleFastInfo.ModuleVersion.Version - }) - } - - [string] ToString() { - return "$($this.Name)($($this.ModuleVersion))" - } - [string] ToUniqueString() { - return "$($this.Name)-$($this.ModuleVersion)-$($this.Location)" - } - - [int] GetHashCode() { - return $this.ToUniqueString().GetHashCode() - } - - [bool] Equals($other) { - return $this.GetHashCode() -eq $other.GetHashCode() - } - - [int] CompareTo($other) { - return $( - switch ($true) { - ($other -isnot 'ModuleFastInfo') { - $this.ToUniqueString().CompareTo([string]$other); break - } - ($this -eq $other) { 0; break } - ($this.Name -ne $other.Name) { $this.Name.CompareTo($other.Name); break } - default { - $this.ModuleVersion.CompareTo($other.ModuleVersion) - } - } - ) - } - - static hidden [bool]Get_Prerelease([PSObject]$i) { - return $i.ModuleVersion.IsPrerelease -or $i.ModuleVersion.HasMetadata - } - - #endregion ImplicitBehaviors -} - -$ModuleFastInfoTypeData = @{ - DefaultDisplayPropertySet = 'Name', 'ModuleVersion', 'Location' - DefaultKeyPropertySet = 'Name', 'ModuleVersion', 'Location' - SerializationMethod = 'SpecificProperties' - PropertySerializationSet = 'Name', 'ModuleVersion', 'Location' - SerializationDepth = 0 -} -[ModuleFastInfo] | Add-Getters -Update-TypeData -TypeName ModuleFastInfo @ModuleFastInfoTypeData -Force -Update-TypeData -TypeName Nuget.Versioning.NugetVersion -SerializationMethod String -Force - - -[NoRunspaceAffinity()] -class ModuleFastSpec { - #These properties are effectively read only thanks to some getter wizardy - - #Name of the Module to Download - hidden [string]$_Name - static hidden [string]Get_Name([PSObject]$i) { return $i._Name } - - #Unique ID of the module. This is optional but detects the rare corruption case if two modules have the same name and version but different GUIDs - hidden [guid]$_Guid - static hidden [guid]Get_Guid([PSObject]$i) { return $i._Guid } - - #NuGet Version Range that specifies what Versions are acceptable. This can be specified as Nuget Version Syntax string - hidden [VersionRange]$_VersionRange - static hidden [VersionRange]Get_VersionRange([PSObject]$i) { return $i._VersionRange } - - #A flag to indicate if prerelease should be included if the name had ! specified (this is done in the constructor) - hidden [bool]$_PreReleaseName - static hidden [bool]Get_PreRelease([PSObject]$i) { - return $i._VersionRange.MinVersion.IsPrerelease -or - $i._VersionRange.MaxVersion.IsPrerelease -or - $i._VersionRange.MinVersion.HasMetadata -or - $i._VersionRange.MaxVersion.HasMetadata -or - $i._PreReleaseName - } - - static hidden [NugetVersion]Get_Min([PSObject]$i) { return $i._VersionRange.MinVersion } - static hidden [NugetVersion]Get_Max([PSObject]$i) { return $i._VersionRange.MaxVersion } - static hidden [NugetVersion]Get_Required([PSObject]$i) { - if ($i.Min -eq $i.Max) { - return $i.Min - } else { - return $null - } - } - - #ModuleSpecification Compatible Getters - static hidden [Version]Get_RequiredVersion([PSObject]$i) { - return $i.Required.Version - } - static hidden [Version]Get_Version([PSObject]$i) { return $i.Min.Version } - static hidden [Version]Get_MaximumVersion([PSObject]$i) { return $i.Max.Version } - - #Constructors - ModuleFastSpec([string]$Name, [string]$RequiredVersion) { - $this.Initialize($Name, "[$RequiredVersion]", [guid]::Empty) - } - - ModuleFastSpec([string]$Name, [string]$RequiredVersion, [string]$Guid) { - $this.Initialize($Name, "[$RequiredVersion]", $Guid) - } - - ModuleFastSpec([string]$Name, [VersionRange]$RequiredVersion) { - $this.Initialize($Name, $RequiredVersion, [guid]::Empty) - } - - ModuleFastSpec([ModuleSpecification]$ModuleSpec) { - $this.Initialize($ModuleSpec) - } - - ModuleFastSpec([ModuleFastInfo]$ModuleFastInfo) { - $RequiredVersion = [VersionRange]::Parse("[$($ModuleFastInfo.ModuleVersion)]") - $this.Initialize($ModuleFastInfo.Name, $requiredVersion, [guid]::Empty) - } - - ModuleFastSpec([string]$Name) { - #Used as a reference handle for TryParse - [ModuleSpecification]$moduleSpec = $null - - switch ($true) { - #Handles a string representation of a modulespecification hashtable - ([ModuleSpecification]::TryParse($Name, [ref]$moduleSpec)) { - $this.Initialize($moduleSpec) - break - } - #There should be no more @{ after the string representation so end here if not parseable - ($Name.contains('@{')) { - throw [ArgumentException]"Cannot convert $Name to a ModuleFastSpec, it does not confirm to the ModuleSpecification syntax but has '@{' in the string." - } - ($Name.contains('>=')) { - $moduleName, [NugetVersion]$lower = $Name.Split('>=') - $this.Initialize($moduleName, $lower, [guid]::Empty) - break - } - ($Name.contains('<=')) { - $moduleName, [NugetVersion]$upper = $Name.Split('<=') - $this.Initialize($moduleName, [VersionRange]::Parse("(,$upper]"), [guid]::Empty) - break - } - ($Name.contains('=')) { - $moduleName, $exactVersion = $Name.Split('=') - $this.Initialize($moduleName, [VersionRange]::Parse("[$exactVersion]"), [guid]::Empty) - break - } - #NuGet Version Syntax for this one - ($Name.contains(':')) { - $moduleName, $range = $Name.Split(':') - $this.Initialize($moduleName, [VersionRange]::Parse($range), [guid]::Empty) - break - } - - ($Name.contains('>')) { - $moduleName, [NugetVersion]$lowerExclusive = $Name.Split('>') - $this.Initialize($moduleName, [VersionRange]::Parse("($lowerExclusive,]"), [guid]::Empty) - break - } - ($Name.contains('<')) { - $moduleName, [NugetVersion]$upperExclusive = $Name.Split('<') - $this.Initialize($moduleName, [VersionRange]::Parse("(,$upperExclusive)"), [guid]::Empty) - break - } - default { - $this.Initialize($Name, $null, [guid]::Empty) - } - } - } - - ModuleFastSpec([System.Collections.IDictionary]$ModuleSpec) { - #TODO: Additional formats - [ModuleSpecification]$ModuleSpec = [ModuleSpecification]::new($ModuleSpec) - $this.Initialize($ModuleSpec) - } - - # This is our fallback case when an object is supplied - ModuleFast([object]$UnsupportedObject) { - throw [NotSupportedException]"Cannot convert $($UnsupportedObject.GetType().FullName) to a ModuleFastSpec, please ensure you provided the correct type of object" - } - - - #TODO: Generic Hashtable/IDictionary constructor for common types - - #HACK: A helper because we can't do constructor chaining in PowerShell - #https://stackoverflow.com/questions/44413206/constructor-chaining-in-powershell-call-other-constructors-in-the-same-class - hidden Initialize([string]$Name, [VersionRange]$Range, [guid]$Guid) { - #HACK: The nulls here are just to satisfy the ternary operator, they go off into the ether and arent returned or used - if (-not $Name) { throw 'Name is required' } - # Strip ! from the beginning or end of the name - $TrimmedName = $Name.Trim('!') - if ($TrimmedName -ne $Name) { - Write-Debug "ModuleSpec $TrimmedName had prerelease identifier ! specified. Will include Prerelease modules" - $this._PreReleaseName = $true - } - - $this._Name = $TrimmedName - $this._VersionRange = $Range ?? [VersionRange]::new() - $this._Guid = $Guid ?? [Guid]::Empty - } - - hidden Initialize([ModuleSpecification]$ModuleSpec) { - [string]$Min = $ModuleSpec.RequiredVersion ?? $ModuleSpec.Version - [string]$Max = $ModuleSpec.RequiredVersion ?? $ModuleSpec.MaximumVersion - $guid = $ModuleSpec.Guid ?? [Guid]::Empty - $range = [VersionRange]::new( - [String]::IsNullOrEmpty($Min) ? $null : $Min, - $true, #Inclusive - [String]::IsNullOrEmpty($Max) ? $null : $Max, - $true, #Inclusive - $null, - "ModuleSpecification: $ModuleSpec" - ) - - $this.Initialize($ModuleSpec.Name, $range, $guid) - } - - #region Methods - - [bool] SatisfiedBy([version]$Version) { - return $this.SatisfiedBy([NuGetVersion]::new($Version, $false)) - } - [bool] SatisfiedBy([version]$Version, [bool]$strictSemVer) { - return $this.SatisfiedBy([NuGetVersion]::new($Version, $strictSemVer)) - } - - [bool] SatisfiedBy([NugetVersion]$Version) { - return $this.SatisfiedBy($Version, $false) - } - - #strictSemVer means [1.0.0,2.0.0) will match 2.0.0-alpha1. Most people don't want this. - [bool] SatisfiedBy([NugetVersion]$Version, [bool]$strictSemVer) { - $range = $this._VersionRange - $strictSatisfies = $range.IsFloating ? - $range.Float.Satisfies($Version) : - $range.Satisfies($Version) - - if ($strictSemVer) { - return $strictSatisfies - } - - if (-not $range.MaxVersion) {return $strictSatisfies} - $max = $range.MaxVersion - $min = $range.MinVersion - - if ( - #Example: Version is 2.0.0-alpha1 and the spec is module:[1.0.0,2.0.0) - $Version.IsPrerelease -and - -not $range.IsMaxInclusive -and - -not $max.IsPrerelease -and - ($max.Major -eq $Version.Major) -and - ($max.Minor -eq $Version.Minor) -and - ($max.Patch -eq $Version.Patch) - #If the minimum matches the maximum and has a prerelease, that means it's a range like (3.0.0-alpha,3.0.0-beta and we want strict matching) - ) - { - #In a special case like (3.0.0-alpha,3.0.0-beta) where the min and max are the same version, we want normal strict semver behavior - if ( - $min -and - $min.Major -eq $max.Major -and - $min.Minor -eq $max.Minor -and - $min.Patch -eq $max.Patch -and - $min.IsPrerelease - ) { - Write-Debug "ModuleFastSpec: $this is being compared to $Version. It was not excluded because the min matches the max and both are prereleases, so normal behavior occured." - return $strictSatisfies - } - - Write-Verbose "ModuleFastSpec: $this is typically satisfied by $Version, but this prerelease of the exclusive maximum version specification was ignored for ease of use. Specify -StrictSemVer to allow pre-releases of excluded versions." - return $false - } - - #Last resort is to use strict matching - return $strictSatisfies - } - - [bool] Overlap([ModuleFastSpec]$other) { - return $this.Overlap($other._VersionRange) - } - - [bool] Overlap([VersionRange]$other) { - [List[VersionRange]]$ranges = @($this._VersionRange, $other) - $subset = [VersionRange]::CommonSubset($ranges) - #If the subset has an explicit version of 0.0.0, this means there was no overlap. - return '(0.0.0, 0.0.0)' -ne $subset - } - - - #endregion Methods - - #region InterfaceImplementations - - #IEquatable - #BUG: We cannot implement IEquatable directly because we need to self-reference ModuleFastSpec before it exists. - #We can however just add Equals() method - - #Implementation of https://learn.microsoft.com/en-us/dotnet/api/system.iequatable-1.equals - [bool]Equals($other) { - return $this.GetHashCode() -eq $other.GetHashCode() - } - #end IEquatable - - [string] ToString() { - $guid = $this._Guid -ne [Guid]::Empty ? " [$($this._Guid)]" : '' - $versionRange = $this._VersionRange.ToString() -eq '(, )' ? '' : " $($this._VersionRange)" - if ($this._VersionRange.MaxVersion -and $this._VersionRange.MaxVersion -eq $this._VersionRange.MinVersion) { - $versionRange = "($($this._VersionRange.MinVersion))" - } - return "$($this._Name)$guid$versionRange" - } - [int] GetHashCode() { - return $this.ToString().GetHashCode() - } - - #IComparable - #Implementation of https://learn.microsoft.com/en-us/dotnet/api/system.icomparable-1.equals - [int]CompareTo($other) { - if ($this.Equals($other)) { return 0 } - if ($other -is [ModuleFastSpec]) { - $other = $other._VersionRange - } - - [NuGetVersion]$version = if ($other -is [VersionRange]) { - if (-not $this.IsRequiredVersion($other)) { - throw [NotSupportedException]"ModuleFastSpec $this has a version range, it must be a single required version e.g. '[1.5.0]'" - } - $other.MaxVersion - } else { - $other - } - - $thisVersion = $this._VersionRange - - if ($thisVersion.Satisfies($Version)) { return 0 } - if ($thisVersion.MinVersion -gt $Version) { return 1 } - if ($thisVersion.MaxVersion -lt $Version) { return -1 } - throw 'Could not compare. This should not happen and is a bug' - return 0 - } - - hidden [bool]IsRequiredVersion([VersionRange]$Version) { - return $Version.MinVersion -ne $Version.MaxVersion -or - -not $Version.HasLowerAndUpperBounds -or - -not $Version.IsMinInclusive -or - -not $Version.IsMaxInclusive - } - - #end IComparable - - #endregion InterfaceImplementations - - #region ImplicitConversions - static [ModuleSpecification] op_Implicit([ModuleFastSpec]$moduleFastSpec) { - $moduleSpecProperties = @{ - ModuleName = $moduleFastSpec.Name - } - if ($moduleFastSpec.Guid -ne [Guid]::Empty) { - $moduleSpecProperties.Guid = $moduleFastSpec.Guid - } - if ($moduleFastSpec.Required) { - [version]$version = $null - [version]::TryParse($moduleFastSpec.Required, [ref]$version) | Out-Null - $moduleSpecProperties.RequiredVersion = $moduleFastSpec.Required.Version - } elseif ($moduleSpecProperties.Min -or $moduleSpecProperties.Max) { - $moduleSpecProperties.ModuleVersion = $moduleFastSpec.Min.Version - $moduleSpecProperties.MaximumVersion = $moduleFastSpec.Max.Version - } else { - $moduleSpecProperties.ModuleVersion = [Version]'0.0' - } - - return [ModuleSpecification]$moduleSpecProperties - } - - #endregion ImplicitConversions -} -[ModuleFastSpec] | Add-Getters - -#endRegion Classes - -#region Helpers - -#This is used as a simple caching mechanism to avoid multiple simultaneous fetches for the same info. For example, Az.Accounts. It will persist for the life of the PowerShell session -[MemoryCache]$SCRIPT:RequestCache = [MemoryCache]::new('PowerShell-ModuleFast-RequestCache') -function Get-ModuleInfoAsync { - [CmdletBinding()] - [OutputType([Task[String]])] - param ( - # The name of the module to search for - [Parameter(Mandatory, ParameterSetName = 'endpoint')][string]$Name, - # The URI of the nuget v3 repository base, e.g. https://pwsh.gallery/index.json - [Parameter(Mandatory, ParameterSetName = 'endpoint')]$Endpoint, - # The path we are calling for the registration. - [Parameter(ParameterSetName = 'endpoint')][string]$Path = 'index.json', - - #The direct URI to the registration endpoint - [Parameter(Mandatory, ParameterSetName = 'uri')][string]$Uri, - - [Parameter(Mandatory)][HttpClient]$HttpClient, - [Parameter(Mandatory)][CancellationToken]$CancellationToken - ) - - if (-not $Uri) { - $ModuleId = $Name - - #This call should be cached by httpclient after first attempt to speed up future calls - $endpointTask = $SCRIPT:RequestCache[$Endpoint] - - if ($endpointTask) { - Write-Debug "REQUEST CACHE HIT for Registration Index $Endpoint" - } else { - Write-Debug ('{0}fetch registration index from {1}' -f ($ModuleId ? "${ModuleId}: " : ''), $Endpoint) - $endpointTask = $HttpClient.GetStringAsync($Endpoint, $CancellationToken) - $SCRIPT:RequestCache[$Endpoint] = $endpointTask - } - - $registrationIndex = $endpointTask.GetAwaiter().GetResult() - - $registrationBase = $registrationIndex - | ConvertFrom-Json - | Select-Object -ExpandProperty resources - | Where-Object { - $_.'@type' -match 'RegistrationsBaseUrl' - } - | Sort-Object -Property '@type' -Descending - | Select-Object -ExpandProperty '@id' -First 1 - - $Uri = "$($registrationBase -replace '/$','')/$($ModuleId.ToLower())/$Path" - } - - $requestTask = $SCRIPT:RequestCache[$Uri] - - if ($requestTask) { - Write-Debug "REQUEST CACHE HIT for $Uri" - #HACK: We need the task to be a unique reference for the context mapping that occurs later on, so this is an easy if obscure way to "clone" the task using PowerShell. - $requestTask = [Task]::WhenAll($requestTask) - } else { - Write-Debug ('{0}fetch info from {1}' -f ($ModuleId ? "${ModuleId}: " : ''), $uri) - $requestTask = $HttpClient.GetStringAsync($uri, $CancellationToken) - $SCRIPT:RequestCache[$Uri] = $requestTask - } - return $requestTask -} - -function Add-DestinationToPSModulePath { - [Diagnostics.CodeAnalysis.SuppressMessageAttribute( - <#Category#>'PSAvoidUsingDoubleQuotesForConstantString', <#CheckId#>$null, Scope = 'Function', - Justification = 'Using string replacement so some double quotes with a constant are deliberate' - )] - - <# - .SYNOPSIS - Adds an existing PowerShell Modules path to the current session as well as the profile - #> - [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] - param( - [Parameter(Mandatory)][string]$Destination, - [switch]$NoProfileUpdate - ) - $ErrorActionPreference = 'Stop' - $Destination = Resolve-Path $Destination #Will error if it doesn't exist - - # Check if the destination is in the PSModulePath. For a default setup this should basically always be true for Mac/Linux - [string[]]$modulePaths = $env:PSModulePath.split([Path]::PathSeparator) - if ($Destination -in $modulePaths) { - Write-Debug "Destination '$Destination' is already in the PSModulePath, we will assume it is already configured correctly" - return - } - - # Generally we only get this far on Windows where the default CurrentUser is in Documents - Write-Verbose "Updating PSModulePath to include $Destination" - $env:PSModulePath = $Destination, $env:PSModulePath -join [Path]::PathSeparator - - if ($NoProfileUpdate) { - Write-Debug 'Skipping updating the profile because -NoProfileUpdate was specified' - return - } - - #TODO: Support other profiles? - $myProfile = $profile.CurrentUserAllHosts - - if (-not (Test-Path $myProfile)) { - if (-not (Approve-Action $myProfile "Allow ModuleFast to work by creating a profile at $myProfile.")) { return } - Write-Verbose 'User All Hosts profile not found, creating one.' - New-Item -ItemType File -Path $myProfile -Force | Out-Null - } - - #Prepare a relative destination if possible using Path.GetRelativePath - foreach ($basePath in [environment]::GetFolderPath('LocalApplicationData'), $Home) { - $relativeDestination = [Path]::GetRelativePath($basePath, $Destination) - if ($relativeDestination -ne $Destination) { - [string]$newDestination = '$([environment]::GetFolderPath(''LocalApplicationData''))' + - [Path]::DirectorySeparatorChar + - $relativeDestination - Write-Verbose "Using relative path $newDestination instead of '$Destination' in profile" - $Destination = $newDestination - break - } - } - Write-Verbose 'Checked for relative destination' - - [string]$profileLine = {if ("##DESTINATION##" -notin ($env:PSModulePath.split([IO.Path]::PathSeparator))) { $env:PSModulePath = "##DESTINATION##" + $([IO.Path]::PathSeparator + $env:PSModulePath) } <#Added by ModuleFast. DO NOT EDIT THIS LINE. If you do not want this, add -NoProfileUpdate to Install-ModuleFast or add the default destination to your powershell.config.json or to your PSModulePath another way.#> } - - #We can't use string formatting because of the braces already present - $profileLine = $profileLine -replace '##DESTINATION##', $Destination - - if ((Get-Content -Raw $myProfile) -notmatch [Regex]::Escape($ProfileLine)) { - if (-not (Approve-Action $myProfile "Allow ModuleFast to work by adding $Destination to your PSModulePath on startup by appending to your CurrentUserAllHosts profile. If you do not want this, add -NoProfileUpdate to Install-ModuleFast or add the specified destination to your powershell.config.json or to your PSModulePath another way.")) { return } - Write-Verbose "Adding $Destination to profile $myProfile" - Add-Content -Path $myProfile -Value "`n`n" - Add-Content -Path $myProfile -Value $ProfileLine - } else { - Write-Verbose "PSModulePath $Destination already in profile, skipping..." - } -} - -function Find-LocalModule { - [OutputType([ModuleFastInfo])] - <# - .SYNOPSIS - Searches local PSModulePath repositories for the first module that satisfies the ModuleSpec criteria - #> - param( - [Parameter(Mandatory)][ModuleFastSpec]$ModuleSpec, - [string[]]$ModulePaths = $($env:PSModulePath.Split([Path]::PathSeparator, [StringSplitOptions]::RemoveEmptyEntries)), - [Switch]$Update, - [ref]$BestCandidate - ) - $ErrorActionPreference = 'Stop' - - if (-not $ModulePaths) { - Write-Warning 'No PSModulePaths found in $env:PSModulePath. If you are doing isolated testing you can disregard this.' - return - } - - #We want to minimize reading the manifest files, so we will do a fast file-based search first and then do a more detailed inspection on high confidence candidate(s). Any module in any folder path that satisfies the spec will be sufficient, we don't care about finding the "latest" version, so we will return the first module that satisfies the spec. We will store potential candidates in this list, with their evaluated "guessed" version based on the folder name and the path. The first items added to the list should be the highest likelihood candidates in Path priority order, so no sorting should be necessary. - - foreach ($modulePath in $ModulePaths) { - [List[[Tuple[Version, string]]]]$candidatePaths = @() - if (-not [Directory]::Exists($modulePath)) { - Write-Debug "${ModuleSpec}: Skipping PSModulePath $modulePath - Configured but does not exist." - $modulePaths = $modulePaths | Where-Object { $_ -ne $modulePath } - continue - } - - #Linux/Mac support requires a case insensitive search on a user supplied variable. - $moduleBaseDir = [Directory]::GetDirectories($modulePath, $moduleSpec.Name, [EnumerationOptions]@{MatchCasing = 'CaseInsensitive' }) - if ($moduleBaseDir.count -gt 1) { throw "$($moduleSpec.Name) folder is ambiguous, please delete one of these folders: $moduleBaseDir" } - if (-not $moduleBaseDir) { - Write-Debug "${ModuleSpec}: Skipping PSModulePath $modulePath - Does not have this module." - continue - } - - #We can attempt a fast-search for modules if the ModuleSpec is for a specific version - $required = $ModuleSpec.Required - if ($required) { - - #If there is a prerelease, we will fetch the folder where the prerelease might live, and verify the manifest later. - [Version]$moduleVersion = Resolve-FolderVersion $required - - $moduleFolder = Join-Path $moduleBaseDir $moduleVersion - $manifestPath = Join-Path $moduleFolder $manifestName - - if (Test-Path $ModuleFolder) { - $candidatePaths.Add([Tuple]::Create([version]$moduleVersion, $manifestPath)) - } - } else { - #Check for versioned module folders next and sort by the folder versions to process them in descending order. - [Directory]::GetDirectories($moduleBaseDir) - | ForEach-Object { - $folder = $PSItem - $version = $null - $isVersion = [Version]::TryParse((Split-Path -Leaf $PSItem), [ref]$version) - if (-not $isVersion) { - Write-Debug "Could not parse $folder in $moduleBaseDir as a valid version. This is either a bad version directory or this folder is a classic module." - return - } - - #Fast filter items that are above the upper bound, we dont need to read these manifests - if ($ModuleSpec.Max -and $version -gt $ModuleSpec.Max.Version) { - Write-Debug "${ModuleSpec}: Skipping $folder - above the upper bound" - return - } - - #We can fast filter items that are below the lower bound, we dont need to read these manifests - if ($ModuleSpec.Min) { - #HACK: Nuget does not correctly convert major.minor.build versions - [version]$originalBaseVersion = ($modulespec.Min.OriginalVersion -split '-')[0] - [Version]$minVersion = $originalBaseVersion.Revision -eq -1 ? $originalBaseVersion : $ModuleSpec.Min.Version - if ($version -lt $minVersion) { - Write-Debug "${ModuleSpec}: Skipping $folder - $version is below the lower bound of $minVersion" - return - } - } - - $candidatePaths.Add([Tuple]::Create([Version]$version, $PSItem)) - } - } - - #Check for a "classic" module if no versioned folders were found - if ($candidatePaths.count -eq 0) { - [string[]]$classicManifestPaths = [Directory]::GetFiles($moduleBaseDir, $manifestName, [EnumerationOptions]@{MatchCasing = 'CaseInsensitive' }) - if ($classicManifestPaths.count -gt 1) { throw "$moduleBaseDir manifest is ambiguous, please delete one of these: $classicManifestPath" } - [string]$classicManifestPath = $classicManifestPaths[0] - if ($classicManifestPath) { - #NOTE: This does result in Import-PowerShellData getting called twice which isn't ideal for performance, but classic modules should be fairly rare and not worth optimizing. - [version]$classicVersion = (Import-ModuleManifest $classicManifestPath).ModuleVersion - Write-Debug "${ModuleSpec}: Found classic module $classicVersion at $moduleBaseDir" - $candidatePaths.Add([Tuple[Version, String]]::new($classicVersion, $moduleBaseDir)) - } - } - - if ($candidatePaths.count -eq 0) { - Write-Debug "${ModuleSpec}: Skipping PSModulePath $modulePath - No installed versions matched the spec." - continue - } - - foreach ($candidateItem in $candidatePaths) { - [version]$version = $candidateItem.Item1 - [string]$folder = $candidateItem.Item2 - - #Make sure this isn't an incomplete installation - if (Test-Path (Join-Path $folder '.incomplete')) { - Write-Warning "${ModuleSpec}: Incomplete installation detected at $folder. Deleting and ignoring." - Remove-Item $folder -Recurse -Force - continue - } - - #Read the module manifest to check for prerelease versions. - $manifestName = "$($ModuleSpec.Name).psd1" - $versionedManifestPath = [Directory]::GetFiles($folder, $manifestName, [EnumerationOptions]@{MatchCasing = 'CaseInsensitive' }) - - if ($versionedManifestPath.count -gt 1) { throw "$folder manifest is ambiguous, this happens on Linux if you have two manifests with different case sensitivity. Please delete one of these: $versionedManifestPath" } - - if (-not $versionedManifestPath) { - Write-Warning "${ModuleSpec}: Found a candidate versioned module folder $folder but no $manifestName manifest was found in the folder. This is an indication of a corrupt module and you should clean this folder up" - continue - } - - #Read the manifest so we can compare prerelease info. If this matches, we have a valid candidate and don't need to check anything further. - $manifestCandidate = ConvertFrom-ModuleManifest $versionedManifestPath[0] - if ($ModuleSpec.Guid -and $ModuleSpec.Guid -ne [Guid]::Empty -and $manifestCandidate.Guid -ne $ModuleSpec.Guid) { - Write-Warning "${ModuleSpec}: A locally installed module $folder that matches the module spec but the manifest GUID $($manifestCandidate.Guid) does not match the expected GUID $($ModuleSpec.Guid) in the spec. Verify your specification is correct otherwise investigate this module for why the GUID does not match." - continue - } - $candidateVersion = $manifestCandidate.ModuleVersion - - if ($ModuleSpec.SatisfiedBy($candidateVersion, $StrictSemVer)) { - if ($Update -and ($ModuleSpec.Max -ne $candidateVersion)) { - Write-Debug "${ModuleSpec}: Skipping $candidateVersion because -Update was specified and the version does not exactly meet the upper bound of the spec or no upper bound was specified at all, meaning there is a possible newer version remotely." - #We can use this ref later to find out if our best remote version matches what is installed without having to read the manifest again - if (-not $bestCandidate.Value[$moduleSpec] -or - $manifestCandidate.ModuleVersion -gt $bestCandidate.Value[$moduleSpec].ModuleVersion - ) { - Write-Debug "${ModuleSpec}: ⬆️ New Best Candidate Version $($manifestCandidate.ModuleVersion)" - $BestCandidate.Value[$moduleSpec] = $manifestCandidate - } - continue - } - - #TODO: Collect InstalledButSatisfied Modules into an array so they can later be referenced in the lockfile and/or plan, right now the lockfile only includes modules that changed. - return $manifestCandidate - } - } - } -} - - -function ConvertTo-AuthenticationHeaderValue ([PSCredential]$Credential) { - $basicCredential = [Convert]::ToBase64String( - [Encoding]::UTF8.GetBytes( - ($Credential.UserName, $Credential.GetNetworkCredential().Password -join ':') - ) - ) - return [Net.Http.Headers.AuthenticationHeaderValue]::New('Basic', $basicCredential) -} - -#Get the hash of a string -function Get-StringHash ([string]$String, [string]$Algorithm = 'SHA256') { - (Get-FileHash -InputStream ([MemoryStream]::new([Encoding]::UTF8.GetBytes($String))) -Algorithm $algorithm).Hash -} - -#Imports a powershell data file or json file for the required spec configuration. -filter ConvertFrom-RequiredSpec { - [CmdletBinding(DefaultParameterSetName = 'File')] - [OutputType([ModuleFastSpec[]])] - param( - [Parameter(Mandatory, ParameterSetName = 'File')][string]$RequiredSpecPath, - [Parameter(Mandatory, ParameterSetName = 'Object')]$RequiredSpec, - [SpecFileType]$SpecFileType - ) - $ErrorActionPreference = 'Stop' - - #If a spec path was specified, resolve it into RequiredSpec - if ($RequiredSpecPath) { - $specFromUri = Read-RequiredSpecFile $RequiredSpecPath - $RequiredSpec = $specFromUri - } - - $PassThruTypes = [string], - [string[]], - [ModuleFastSpec], - [ModuleFastSpec[]], - [ModuleSpecification[]], - [ModuleSpecification] - - if ($RequiredSpec.GetType() -in $PassThruTypes) { return [ModuleFastSpec[]]$requiredSpec } - - if ($RequiredSpec -is [PSCustomObject] -and $RequiredSpec.psobject.baseobject -isnot [IDictionary]) { - Write-Debug 'PSCustomObject-based Spec detected, converting to hashtable' - $requireHT = @{} - $RequiredSpec.psobject.Properties - | ForEach-Object { - $requireHT.Add($_.Name, $_.Value) - } - #Will be process by IDictionary case below - $RequiredSpec = $requireHT - } - - if ($RequiredSpec -is [IDictionary]) { - - if ($SpecFileType -eq 'AutoDetect') { - $SpecFileType = Select-RequiredSpecFileType $RequiredSpec - } - if ($SpecFileType -eq 'AutoDetect') {throw 'There was an unexpected error processing the spec file type. This is a bug that should be reported.'} - - switch ($SpecFileType) { - ([SpecFileType]::PSDepend) { - Write-Debug 'Requires Parse: PSDepend Spec specified, evaluating...' - return ConvertFrom-PSDepend $requiredSpec - } - ([SpecFileType]::PSResourceGet) { - Write-Debug 'Requires Parse: PSResourceGet Spec specified, evaluating...' - return ConvertFrom-PSResourceGet $requiredSpec - } - } - - foreach ($kv in $RequiredSpec.GetEnumerator()) { - if ($kv.Value -is [IDictionary]) { - throw [NotSupportedException]'ModuleFast SpecFile detected but the value is a hashtable. This is not supported. Try using the -SpecFileType parameter if you expected another format' - } - if ($kv.Value -isnot [string]) { - throw [NotSupportedException]'Only strings and hashtables are supported on the right hand side of the = operator.' - } - if ($kv.Value -eq 'latest') { - [ModuleFastSpec]"$($kv.Name)" - continue - } - if ($kv.Value -as [NuGetVersion]) { - [ModuleFastSpec]::new($kv.Name, $kv.Value) - continue - } - if ($kv.Value -as [VersionRange]) { - [ModuleFastSpec]::new($kv.Name, ($kv.Value -as [VersionRange])) - continue - } - - #All other potential options (<=, @, :, etc.) are a direct merge - try { - [ModuleFastSpec]"$($kv.Name)$($kv.Value)" - } catch { - throw [NotSupportedException]"Could not parse $($kv.Value) as a valid ModuleFastSpec. Check out the simplified syntax instructions for your options." - } - } - return - } - - if ($RequiredSpec -is [Object[]] -and ($true -notin $RequiredSpec.GetEnumerator().Foreach{ $PSItem -isnot [string] })) { - Write-Debug 'RequiredData array detected and contains all string objects. Converting to string[]' - $RequiredSpec = [string[]]$RequiredSpec - } - - throw [InvalidDataException]'Could not evaluate the Required Specification to a known format.' -} - -function Find-RequiredSpecFile ([string]$Path) { - Write-Debug "Attempting to find Required Specfile(s) at $Path" - - $resolvedPath = Resolve-Path $Path - - $requireFiles = Get-Item $resolvedPath/*.requires.* -ErrorAction SilentlyContinue - | Where-Object { - $extension = [Path]::GetExtension($_.FullName) - $extension -in '.psd1', '.ps1', '.psm1', '.json', '.jsonc' - } - - if (-not $requireFiles) { - throw [NotSupportedException]"Could not find any required spec files in $Path. Verify the path is correct or provide Module Specifications either via -Path or -Specification" - } - return $requireFiles -} - -function Select-RequiredSpecFileType ([IDictionary]$requiredSpec) { - Write-Debug 'SpecFile Parse: Attempting to auto-detect SpecFile type' - foreach ($key in $requiredSpec.Keys) { - if ($key -match '::|/') { - Write-Debug 'SpecFile Parse: Auto-detected SpecFile type as PSDepend due to presence of :: or / in keys' - return [SpecFileType]::PSDepend - } - if ($key -eq 'PSDependOptions') { - Write-Debug 'SpecFile Parse: Auto-detected SpecFile type as PSDepend due to presence of PSDependOptions key' - return [SpecFileType]::PSDepend - } - - if ($requiredSpec[$key] -is [IDictionary]) { - if ($requiredSpec[$key].ContainsKey('DependencyType')) { - Write-Debug 'SpecFile Parse: Auto-detected SpecFile type as PSDepend due to presence of DependencyType key' - return [SpecFileType]::PSDepend - } - if ($requiredSpec[$key].ContainsKey('Repository') -or $requiredSpec[$key].ContainsKey('Version')) { - Write-Debug 'SpecFile Parse: Auto-detected SpecFile type as PSResourceGet/RequiredModules due to presence of Repository or Version key' - return [SpecFileType]::PSResourceGet - } - } - } - Write-Debug 'SpecFile Parse: Auto-detected SpecFile type as ModuleFast due to lack of other indicators' - return [SpecFileType]::ModuleFast -} - -function Read-RequiredSpecFile ($RequiredSpecPath) { - if ($uri.scheme -in 'http', 'https') { - [string]$content = (Invoke-WebRequest -Uri $uri).Content - if ($content.StartsWith('@{')) { - #HACK: Cannot read PowerShell Data Files from a string, the interface is private, so we write to a temp file as a workaround. - $tempFile = [io.path]::GetTempFileName() - $content > $tempFile - return Import-ModuleManifest -Path $tempFile - } else { - $json = ConvertFrom-Json $content -Depth 5 - return $json - } - } - - #Assume this is a local if a URL above didn't match - $resolvedPath = Resolve-Path $RequiredSpecPath - $extension = [Path]::GetExtension($resolvedPath) - - if ($extension -eq '.psd1') { - $manifestData = Import-ModuleManifest -Path $resolvedPath - if ($manifestData.ModuleVersion) { - [ModuleSpecification[]]$requiredModules = $manifestData.RequiredModules - Write-Debug 'Detected a Module Manifest, evaluating RequiredModules' - if ($requiredModules.count -eq 0) { - throw [InvalidDataException]'The manifest does not have a RequiredModules key so ModuleFast does not know what this module requires. See Get-Help about_module_manifests for more.' - } - return , $requiredModules - } else { - Write-Debug 'Did not detect a module manifest, passing through as-is' - return $manifestData - } - } - - if ($extension -in '.ps1', '.psm1') { - Write-Debug 'PowerShell Script/Module file detected, checking for #Requires' - $ast = [Parser]::ParseFile($resolvedPath, [ref]$null, [ref]$null) - [ModuleSpecification[]]$requiredModules = $ast.ScriptRequirements.RequiredModules - - if ($RequiredModules.count -eq 0) { - throw [NotSupportedException]'The script does not have a #Requires -Module statement so ModuleFast does not know what this module requires. See Get-Help about_requires for more.' - } - return , $requiredModules - } - - if ($extension -in '.json', '.jsonc') { - $json = Get-Content -Path $resolvedPath -Raw | ConvertFrom-Json -Depth 5 - if ($json -is [Object[]] -and $false -notin $json.getenumerator().foreach{ $_ -is [string] }) { - Write-Debug 'Detected a JSON array of strings, converting to string[]' - return , [string[]]$json - } - return $json - } - - throw [NotSupportedException]'Only .ps1, psm1, .psd1, and .json files are supported to import to this command' -} - -filter Resolve-FolderVersion([NuGetVersion]$version) { - if ($version.IsLegacyVersion -or $version.OriginalVersion -match '\d+\.\d+\.\d+\.\d+') { - return $version.version - } - [Version]::new($version.Major, $version.Minor, $version.Patch) -} - -filter ConvertFrom-ModuleManifest { - [CmdletBinding()] - [OutputType([ModuleFastInfo])] - param( - [Parameter(Mandatory)][string]$ManifestPath - ) - $ErrorActionPreference = 'Stop' - - $ManifestName = Split-Path -Path $ManifestPath -LeafBase - $manifestData = Import-ModuleManifest -Path $ManifestPath - - [Version]$manifestVersionData = $null - if (-not [Version]::TryParse($manifestData.ModuleVersion, [ref]$manifestVersionData)) { - throw [InvalidDataException]"The manifest at $ManifestPath has an invalid ModuleVersion $($manifestData.ModuleVersion). This is probably an invalid or corrupt manifest" - } - - [NuGetVersion]$manifestVersion = [NuGetVersion]::new( - $manifestVersionData, - $manifestData.PrivateData.PSData.Prerelease - ) - - $moduleFastInfo = [ModuleFastInfo]::new($ManifestName, $manifestVersion, $ManifestPath) - if ($manifestVersion.Guid) { - $moduleFastInfo.Guid = $manifestVersion.Guid - } - return $moduleFastInfo -} -#Fixes an issue where ShouldProcess will not respect ConfirmPreference if -Debug is specified - - -function Approve-Action { - [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSShouldProcess", '', Scope='Function')] - param( - [ValidateNotNullOrEmpty()][string]$Target, - [ValidateNotNullOrEmpty()][string]$Action, - $ThisCmdlet = $PSCmdlet - ) - $ShouldProcessMessage = 'Performing the operation "{0}" on target "{1}"' -f $Action, $Target - if ($ENV:CI -or $CI) { - Write-Verbose "$ShouldProcessMessage (Auto-Confirmed because `$ENV:CI is specified)" - return $true - } - if ($ConfirmPreference -eq 'None') { - Write-Verbose "$ShouldProcessMessage (Auto-Confirmed because `$ConfirmPreference is set to 'None')" - return $true - } - - return $ThisCmdlet.ShouldProcess($Target, $Action) -} - -#Fetches the module path for the current user or all users. -#HACK: Uses a private API until https://github.com/PowerShell/PowerShell/issues/15552 is resolved -function Get-PSDefaultModulePath ([Switch]$AllUsers) { - $scopeType = [Management.Automation.Configuration.ConfigScope] - $pscType = $scopeType. - Assembly. - GetType('System.Management.Automation.Configuration.PowerShellConfig') - - $pscInstance = $pscType. - GetField('Instance', [Reflection.BindingFlags]'Static,NonPublic'). - GetValue($null) - - $getModulePathMethod = $pscType.GetMethod('GetModulePath', [Reflection.BindingFlags]'Instance,NonPublic') - - if ($AllUsers) { - $getModulePathMethod.Invoke($pscInstance, $scopeType::AllUsers) ?? [Management.Automation.ModuleIntrinsics]::GetPSModulePath('BuiltIn') - } else { - $getModulePathMethod.Invoke($pscInstance, $scopeType::CurrentUser) ?? [Management.Automation.ModuleIntrinsics]::GetPSModulePath('User') - } -} - -#endregion Helpers - -### ISSUES -# FIXME: When doing directory match comparison for local modules, need to preserve original folder name. See: Reflection 4.8 -# To fix this we will just use the name out of the module.psd1 when installing -# FIXME: DBops dependency version issue - Set-Alias imf -Value Install-ModuleFast -Export-ModuleMember -Function Get-ModuleFastPlan, Install-ModuleFast, Clear-ModuleFastCache -Alias imf diff --git a/ModuleFast.slnx b/ModuleFast.slnx new file mode 100644 index 0000000..c3f60ba --- /dev/null +++ b/ModuleFast.slnx @@ -0,0 +1,3 @@ + + + diff --git a/ModuleFast.tests.ps1 b/ModuleFast.tests.ps1 index 75e7ccc..c00a960 100644 --- a/ModuleFast.tests.ps1 +++ b/ModuleFast.tests.ps1 @@ -3,9 +3,9 @@ using namespace Microsoft.PowerShell.Commands using namespace System.Collections.Generic using namespace System.Diagnostics.CodeAnalysis using namespace NuGet.Versioning +using namespace ModuleFast -. $PSScriptRoot/ModuleFast.ps1 -ImportNuGetVersioning -Import-Module $PSScriptRoot/ModuleFast.psm1 -Force +Import-Module $PSScriptRoot/ModuleFast.psd1 -Force BeforeAll { if ($env:MFURI) { @@ -13,77 +13,77 @@ BeforeAll { } } -InModuleScope 'ModuleFast' { - Describe 'ModuleFastSpec' { - Context 'Constructors' { - It 'Getters' { - $spec = [ModuleFastSpec]'Test' - 'Name', 'Guid', 'Min', 'Max', 'Required' | ForEach-Object { - $spec.PSObject.Properties.name | Should -Contain $PSItem - } - } - - It 'Name' { - $spec = [ModuleFastSpec]'Test' - $spec.Name | Should -Be 'Test' - $spec.Guid | Should -Be ([Guid]::Empty) - $spec.Min | Should -BeNull - $spec.Max | Should -BeNull - $spec.Required | Should -BeNull +# ModuleFastSpec is a public C# class — no InModuleScope needed +Describe 'ModuleFastSpec' { + Context 'Constructors' { + It 'Getters' { + $spec = [ModuleFastSpec]'Test' + 'Name', 'Guid', 'Min', 'Max', 'Required' | ForEach-Object { + $spec.PSObject.Properties.name | Should -Contain $PSItem } + } - It 'Has non-settable properties' { - $spec = [ModuleFastSpec]'Test' - { $spec.Min = '1' } | Should -Throw - { $spec.Max = '1' } | Should -Throw - { $spec.Required = '1' } | Should -Throw - { $spec.Name = 'fake' } | Should -Throw - { $spec.Guid = New-Guid } | Should -Throw - } + It 'Name' { + $spec = [ModuleFastSpec]'Test' + $spec.Name | Should -Be 'Test' + $spec.Guid | Should -Be ([Guid]::Empty) + $spec.Min | Should -BeNull + $spec.Max | Should -BeNull + $spec.Required | Should -BeNull + } - It 'ModuleSpecification' { - $in = [ModuleSpecification]@{ - ModuleName = 'Test' - ModuleVersion = '2.1.5' - } - $spec = [ModuleFastSpec]$in - $spec.Name | Should -Be 'Test' - $spec.Guid | Should -Be ([Guid]::Empty) - $spec.Min | Should -Be '2.1.5' - $spec.Max | Should -BeNull - $spec.Required | Should -BeNull - } + It 'Has non-settable properties' { + $spec = [ModuleFastSpec]'Test' + { $spec.Min = '1' } | Should -Throw + { $spec.Max = '1' } | Should -Throw + { $spec.Required = '1' } | Should -Throw + { $spec.Name = 'fake' } | Should -Throw + { $spec.Guid = New-Guid } | Should -Throw } - Context 'ModuleSpecification Conversion' { - It 'Name' { - $spec = [ModuleSpecification][ModuleFastSpec]'Test' - $spec.Name | Should -Be 'Test' - $spec.Version | Should -Be '0.0' - $spec.RequiredVersion | Should -BeNull - $spec.MaximumVersion | Should -BeNull - } - It 'RequiredVersion' { - $spec = [ModuleSpecification][ModuleFastSpec]::new('Test', '1.2.3') - $spec.Name | Should -Be 'Test' - $spec.RequiredVersion | Should -Be '1.2.3.0' - $spec.Version | Should -BeNull - $spec.MaximumVersion | Should -BeNull + It 'ModuleSpecification' { + $in = [ModuleSpecification]@{ + ModuleName = 'Test' + ModuleVersion = '2.1.5' } + $spec = [ModuleFastSpec]$in + $spec.Name | Should -Be 'Test' + $spec.Guid | Should -Be ([Guid]::Empty) + $spec.Min | Should -Be '2.1.5' + $spec.Max | Should -BeNull + $spec.Required | Should -BeNull } } - Describe 'Import-ModuleManifest' { - It 'Reads Dynamic Manifest' { - $Mocks = "$PSScriptRoot/Test/Mocks" - $manifest = Import-ModuleManifest "$Mocks/Dynamic.psd1" - $manifest | Should -BeOfType [System.Collections.Hashtable] - $manifest.ModuleVersion | Should -Be '1.0.0' - $manifest.RootModule | Should -Be 'coreclr\PrtgAPI.PowerShell.dll' + Context 'ModuleSpecification Conversion' { + It 'Name' { + $spec = [ModuleSpecification][ModuleFastSpec]'Test' + $spec.Name | Should -Be 'Test' + $spec.Version | Should -Be '0.0' + $spec.RequiredVersion | Should -BeNull + $spec.MaximumVersion | Should -BeNull + } + It 'RequiredVersion' { + $spec = [ModuleSpecification][ModuleFastSpec]::new('Test', '1.2.3') + $spec.Name | Should -Be 'Test' + $spec.RequiredVersion | Should -Be '1.2.3.0' + $spec.Version | Should -BeNull + $spec.MaximumVersion | Should -BeNull } } } +# Import-ModuleManifest is now a binary cmdlet — no InModuleScope needed +Describe 'Import-ModuleManifest' { + It 'Reads Dynamic Manifest' { + $Mocks = "$PSScriptRoot/Test/Mocks" + $manifest = Import-ModuleManifest "$Mocks/Dynamic.psd1" + $manifest | Should -BeOfType [System.Collections.Hashtable] + $manifest.ModuleVersion | Should -Be '1.0.0' + $manifest.RootModule | Should -Be 'coreclr\PrtgAPI.PowerShell.dll' + } +} + Describe 'Get-ModuleFastPlan' -Tag 'E2E' { BeforeAll { $SCRIPT:__existingPSModulePath = $env:PSModulePath diff --git a/Source/ModuleFast/Commands/ClearModuleFastCacheCommand.cs b/Source/ModuleFast/Commands/ClearModuleFastCacheCommand.cs new file mode 100644 index 0000000..41b15d4 --- /dev/null +++ b/Source/ModuleFast/Commands/ClearModuleFastCacheCommand.cs @@ -0,0 +1,13 @@ +using System.Management.Automation; + +namespace ModuleFast.Commands; + +[Cmdlet(VerbsCommon.Clear, "ModuleFastCache")] +public class ClearModuleFastCacheCommand : PSCmdlet +{ + protected override void ProcessRecord() + { + WriteDebug("Flushing ModuleFast Request Cache"); + ModuleFastCache.Instance.Clear(); + } +} \ No newline at end of file diff --git a/Source/ModuleFast/Commands/GetModuleFastPlanCommand.cs b/Source/ModuleFast/Commands/GetModuleFastPlanCommand.cs new file mode 100644 index 0000000..9bfdcc0 --- /dev/null +++ b/Source/ModuleFast/Commands/GetModuleFastPlanCommand.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; +using System.Management.Automation; +using System.Threading; + +namespace ModuleFast.Commands; + +/// +/// THIS COMMAND IS DEPRECATED AND WILL NOT RECEIVE PARAMETER UPDATES. Please use Install-ModuleFast -Plan instead. +/// +[Cmdlet(VerbsCommon.Get, "ModuleFastPlan")] +[OutputType(typeof(ModuleFastInfo))] +public class GetModuleFastPlanCommand : PSCmdlet +{ + [Parameter(Position = 0, Mandatory = true, ValueFromPipeline = true)] + [Alias("Name")] + public ModuleFastSpec[]? Specification { get; set; } + + [Parameter] + public string Source { get; set; } = "https://pwsh.gallery/index.json"; + + [Parameter] + public SwitchParameter Prerelease { get; set; } + + [Parameter] + public SwitchParameter Update { get; set; } + + [Parameter] + public PSCredential? Credential { get; set; } + + [Parameter] + public int Timeout { get; set; } = 30; + + [Parameter] + public string? Destination { get; set; } + + [Parameter] + public SwitchParameter DestinationOnly { get; set; } + + [Parameter] + public SwitchParameter StrictSemVer { get; set; } + + private readonly HashSet _specs = new(); + + protected override void ProcessRecord() + { + foreach (var spec in Specification ?? []) + _specs.Add(spec); + } + + protected override void EndProcessing() + { + if (Update) ModuleFastCache.Instance.Clear(); + + // Normalize source + if (Uri.TryCreate(Source, UriKind.Absolute, out var srcUri) && + srcUri.Scheme is not "http" and not "https") + { + Source = $"https://{Source}/index.json"; + } + + var httpClient = ModuleFastClient.Create(Credential, Timeout); + var planner = new ModuleFastPlanner(httpClient, Source); + + string[] modulePaths; + if (DestinationOnly && !string.IsNullOrEmpty(Destination)) + { + modulePaths = [Destination]; + } + else if (!string.IsNullOrEmpty(Destination)) + { + var envPaths = Environment.GetEnvironmentVariable("PSModulePath") + ?.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) ?? []; + modulePaths = [Destination, .. envPaths]; + } + else + { + modulePaths = Environment.GetEnvironmentVariable("PSModulePath") + ?.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) ?? []; + } + + try + { + var task = planner.GetPlanAsync( + _specs, + modulePaths, + Update, + Prerelease, + StrictSemVer, + DestinationOnly, + CancellationToken.None, + this); + + var plan = task.GetAwaiter().GetResult(); + foreach (var info in plan) + WriteObject(info); + } + catch (Exception ex) when (ex is not PipelineStoppedException) + { + ThrowTerminatingError(new ErrorRecord(ex, "GetModuleFastPlanFailed", ErrorCategory.NotSpecified, null)); + } + } +} \ No newline at end of file diff --git a/Source/ModuleFast/Commands/ImportModuleManifestCommand.cs b/Source/ModuleFast/Commands/ImportModuleManifestCommand.cs new file mode 100644 index 0000000..7ec4020 --- /dev/null +++ b/Source/ModuleFast/Commands/ImportModuleManifestCommand.cs @@ -0,0 +1,39 @@ +using System.Collections; +using System.Management.Automation; + +namespace ModuleFast.Commands; + +/// +/// Imports a module manifest from a path, handling dynamic manifest formats. +/// NOTE: This cmdlet is primarily for internal use and testing. +/// +[Cmdlet(VerbsData.Import, "ModuleManifest")] +[OutputType(typeof(Hashtable))] +public class ImportModuleManifestCommand : PSCmdlet +{ + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true)] + public string? Path { get; set; } + + protected override void ProcessRecord() + { + if (string.IsNullOrEmpty(Path)) + { + ThrowTerminatingError(new ErrorRecord( + new ArgumentNullException(nameof(Path)), + "PathRequired", + ErrorCategory.InvalidArgument, + null)); + return; + } + + try + { + var result = ModuleManifestReader.ImportModuleManifest(Path, this); + WriteObject(result); + } + catch (Exception ex) + { + ThrowTerminatingError(new ErrorRecord(ex, "ImportModuleManifestFailed", ErrorCategory.ReadError, Path)); + } + } +} \ No newline at end of file diff --git a/Source/ModuleFast/Commands/InstallModuleFastCommand.cs b/Source/ModuleFast/Commands/InstallModuleFastCommand.cs new file mode 100644 index 0000000..d54d587 --- /dev/null +++ b/Source/ModuleFast/Commands/InstallModuleFastCommand.cs @@ -0,0 +1,321 @@ +using System.Collections.Generic; +using System.Management.Automation; +using System.Text.Json; +using System.Threading; + +namespace ModuleFast.Commands; + +[Cmdlet(VerbsLifecycle.Install, "ModuleFast", + SupportsShouldProcess = true, + DefaultParameterSetName = "Specification")] +[OutputType(typeof(ModuleFastInfo))] +public class InstallModuleFastCommand : PSCmdlet +{ + [Alias("Name", "ModuleToInstall", "ModulesToInstall")] + [AllowNull] + [AllowEmptyCollection] + [Parameter(Position = 0, ValueFromPipeline = true, ParameterSetName = "Specification")] + public ModuleFastSpec[]? Specification { get; set; } + + [Parameter(Mandatory = true, ParameterSetName = "Path")] + public string? Path { get; set; } + + [Parameter(ParameterSetName = "Path")] + public SpecFileType SpecFileType { get; set; } = SpecFileType.AutoDetect; + + [Parameter] + public string? Destination { get; set; } + + [Parameter] + public string Source { get; set; } = "https://pwsh.gallery/index.json"; + + [Parameter] + public PSCredential? Credential { get; set; } + + [Parameter] + public SwitchParameter NoPSModulePathUpdate { get; set; } + + [Parameter] + public SwitchParameter NoProfileUpdate { get; set; } + + [Parameter] + public SwitchParameter Update { get; set; } + + [Parameter] + public SwitchParameter Prerelease { get; set; } + + [Parameter] + public SwitchParameter CI { get; set; } + + [Parameter] + public SwitchParameter DestinationOnly { get; set; } + + [Parameter] + public int ThrottleLimit { get; set; } = Environment.ProcessorCount; + + [Parameter] + public string CILockFilePath { get; set; } = System.IO.Path.Combine( + Environment.CurrentDirectory, "requires.lock.json"); + + [Parameter(Mandatory = true, ValueFromPipeline = true, ParameterSetName = "ModuleFastInfo")] + public ModuleFastInfo[]? ModuleFastInfo { get; set; } + + [Parameter] + public SwitchParameter Plan { get; set; } + + [Parameter] + public SwitchParameter PassThru { get; set; } + + [Parameter] + public InstallScope? Scope { get; set; } + + [Parameter] + public int Timeout { get; set; } = 30; + + [Parameter] + public SwitchParameter StrictSemVer { get; set; } + + private readonly HashSet _modulesToInstall = new(); + private readonly List _installPlan = new(); + private CancellationTokenSource? _cancelSource; + private System.Net.Http.HttpClient? _httpClient; + + protected override void BeginProcessing() + { + if (Update) ModuleFastCache.Instance.Clear(); + + // Normalize source + if (Uri.TryCreate(Source, UriKind.Absolute, out var srcUri) && + srcUri.Scheme is not "http" and not "https") + { + Source = $"https://{Source}/index.json"; + } + + var defaultRepoPath = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "powershell", "Modules"); + + if (string.IsNullOrEmpty(Destination)) + { + // Map scope to destination + if (Scope == InstallScope.CurrentUser) + { + // Use legacy documents path + var docsPath = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), + "PowerShell", "Modules"); + Destination = docsPath; + } + else + { + Destination = PathHelper.GetPSDefaultModulePath(allUsers: Scope == InstallScope.AllUsers); + + if (OperatingSystem.IsWindows() && Scope != InstallScope.CurrentUser) + { + var defaultWindowsPath = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), + "PowerShell", "Modules"); + if (string.Equals(Destination, defaultWindowsPath, StringComparison.OrdinalIgnoreCase)) + { + WriteDebug($"Windows Documents module folder detected. Changing to {defaultRepoPath}"); + Destination = defaultRepoPath; + } + } + } + } + + if (string.IsNullOrEmpty(Destination)) + ThrowTerminatingError(new ErrorRecord( + new InvalidOperationException("Failed to determine destination path."), + "DestinationNotFound", ErrorCategory.InvalidOperation, null)); + + if (!Directory.Exists(Destination)) + { + if (string.Equals(Destination, defaultRepoPath, StringComparison.OrdinalIgnoreCase) || + PathHelper.ApproveAction(Destination, "Create Destination Folder", this)) + { + Directory.CreateDirectory(Destination!); + } + } + + Destination = System.IO.Path.GetFullPath(Destination!); + + if (!NoPSModulePathUpdate) + { + var modulePaths = (Environment.GetEnvironmentVariable("PSModulePath") ?? "") + .Split(System.IO.Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); + if (!modulePaths.Contains(Destination, StringComparer.OrdinalIgnoreCase)) + { + PathHelper.AddDestinationToPSModulePath(Destination, NoProfileUpdate, this); + } + } + + _httpClient = ModuleFastClient.Create(Credential, Timeout); + _cancelSource = new CancellationTokenSource(); + _cancelSource.CancelAfter(TimeSpan.FromSeconds(Timeout * 10)); // overall timeout + } + + protected override void ProcessRecord() + { + switch (ParameterSetName) + { + case "Specification": + foreach (var spec in Specification ?? []) + { + if (!_modulesToInstall.Add(spec)) + WriteWarning($"{spec} was specified twice, skipping duplicate."); + } + break; + + case "ModuleFastInfo": + foreach (var info in ModuleFastInfo ?? []) + _installPlan.Add(info); + break; + + case "Path": + var paths = new List(); + if (string.IsNullOrEmpty(Path)) break; + + var pathItem = new FileInfo(Path!); + if (pathItem.Attributes.HasFlag(FileAttributes.Directory)) + { + paths.AddRange(SpecFileReader.FindRequiredSpecFiles(Path!)); + } + else + { + paths.Add(Path!); + } + + foreach (var p in paths) + { + var specs = SpecFileReader.ConvertFromRequiredSpec(p, SpecFileType, this); + foreach (var spec in specs) + _modulesToInstall.Add(spec); + } + break; + } + } + + protected override void EndProcessing() + { + try + { + var ct = _cancelSource?.Token ?? CancellationToken.None; + + ModuleFastInfo[] finalInstallPlan; + + if (_installPlan.Count > 0) + { + finalInstallPlan = _installPlan.ToArray(); + } + else + { + // Auto-detect spec files if nothing was specified + if (_modulesToInstall.Count == 0 && ParameterSetName == "Specification") + { + WriteVerbose("🔎 No modules specified. Beginning SpecFile detection..."); + + if (CI && File.Exists(CILockFilePath)) + { + WriteDebug($"Found lockfile at {CILockFilePath}. Using for specification evaluation."); + var lockSpecs = SpecFileReader.ConvertFromRequiredSpec(CILockFilePath, SpecFileType.AutoDetect, this); + foreach (var spec in lockSpecs) + _modulesToInstall.Add(spec); + if (Update) + { + WriteVerbose("-Update specified but lockfile found. Ignoring -Update."); + Update = false; + } + } + else + { + var specFiles = SpecFileReader.FindRequiredSpecFiles(Environment.CurrentDirectory); + if (specFiles == null || !specFiles.Any()) + { + WriteWarning($"No specfiles found in {Environment.CurrentDirectory}."); + } + else + { + foreach (var specFile in specFiles) + { + WriteVerbose($"Found Specfile {specFile}. Evaluating..."); + var fileSpecs = SpecFileReader.ConvertFromRequiredSpec(specFile, SpecFileType, this); + foreach (var spec in fileSpecs) + _modulesToInstall.Add(spec); + } + } + } + } + + if (_modulesToInstall.Count == 0) + ThrowTerminatingError(new ErrorRecord( + new InvalidDataException("No module specifications found to evaluate."), + "NoSpecifications", ErrorCategory.InvalidData, null)); + + WriteProgress(new ProgressRecord(1, "Install-ModuleFast", "Plan") { PercentComplete = 1 }); + + string[] modulePaths; + if (DestinationOnly) + modulePaths = [Destination!]; + else + modulePaths = Environment.GetEnvironmentVariable("PSModulePath") + ?.Split(System.IO.Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) ?? []; + + var planner = new ModuleFastPlanner(_httpClient!, Source); + var planTask = planner.GetPlanAsync( + _modulesToInstall, modulePaths, Update, Prerelease, StrictSemVer, DestinationOnly, ct, this); + var planSet = planTask.GetAwaiter().GetResult(); + finalInstallPlan = planSet.ToArray(); + } + + if (finalInstallPlan.Length == 0) + { + var msg = $"✅ {_modulesToInstall.Count} Module Specifications have all been satisfied by installed modules. If you would like to check for newer versions remotely, specify -Update"; + WriteVerbose(msg); + return; + } + + if (Plan || !PathHelper.ApproveAction(Destination!, $"Install {finalInstallPlan.Length} Modules", this)) + { + if (Plan) + WriteVerbose($"📑 -Plan was specified. Returning a plan including {finalInstallPlan.Length} Module Specifications"); + foreach (var info in finalInstallPlan) + WriteObject(info); + } + else + { + WriteProgress(new ProgressRecord(1, "Install-ModuleFast", $"Installing: {finalInstallPlan.Length} Modules") { PercentComplete = 50 }); + + var installer = new ModuleFastInstaller(_httpClient!); + var installTask = installer.InstallModulesAsync(finalInstallPlan, Destination!, Update || ParameterSetName == "ModuleFastInfo", ct, this); + var installedModules = installTask.GetAwaiter().GetResult(); + + WriteProgress(new ProgressRecord(1, "Install-ModuleFast", "Completed") { RecordType = ProgressRecordType.Completed }); + WriteVerbose("✅ All required modules installed! Exiting."); + + if (PassThru) + foreach (var m in installedModules) + WriteObject(m); + + if (CI) + { + WriteVerbose($"Writing lockfile to {CILockFilePath}"); + var lockFile = new Dictionary(); + foreach (var m in finalInstallPlan) + lockFile[m.Name] = m.ModuleVersion.ToString(); + + var json = JsonSerializer.Serialize(lockFile, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(CILockFilePath, json); + } + } + } + catch (Exception ex) when (ex is not PipelineStoppedException) + { + ThrowTerminatingError(new ErrorRecord(ex, "InstallModuleFastFailed", ErrorCategory.NotSpecified, null)); + } + finally + { + _cancelSource?.Dispose(); + } + } +} \ No newline at end of file diff --git a/Source/ModuleFast/LocalModuleFinder.cs b/Source/ModuleFast/LocalModuleFinder.cs new file mode 100644 index 0000000..490d5ee --- /dev/null +++ b/Source/ModuleFast/LocalModuleFinder.cs @@ -0,0 +1,195 @@ +using System.Management.Automation; +using System.Text.RegularExpressions; + +using NuGet.Versioning; + +namespace ModuleFast; + +public static class LocalModuleFinder +{ + /// + /// Resolves the folder version from a NuGetVersion: 4-part stays as-is, 3-part strips trailing .0. + /// + public static Version ResolveFolderVersion(NuGetVersion version) + { + if (version.IsLegacyVersion || + Regex.IsMatch(version.OriginalVersion ?? "", @"^\d+\.\d+\.\d+\.\d+$")) + return version.Version; + return new Version(version.Major, version.Minor, version.Patch); + } + + /// + /// Searches local PSModulePaths for the first module that satisfies the ModuleSpec criteria. + /// Returns null if no match found. + /// + public static ModuleFastInfo? FindLocalModule( + ModuleFastSpec spec, + string[]? modulePaths, + bool update, + Dictionary? bestCandidates, + bool strictSemVer, + PSCmdlet? cmdlet = null) + { + if (modulePaths == null || modulePaths.Length == 0) + { + cmdlet?.WriteWarning("No PSModulePaths found. If you are doing isolated testing you can disregard this."); + return null; + } + + foreach (var modulePath in modulePaths) + { + if (!Directory.Exists(modulePath)) + { + cmdlet?.WriteDebug($"{spec}: Skipping PSModulePath {modulePath} - Configured but does not exist."); + continue; + } + + // Case-insensitive search for module base dir + var moduleDirs = Directory.GetDirectories(modulePath, spec.Name, + new EnumerationOptions { MatchCasing = MatchCasing.CaseInsensitive }); + + if (moduleDirs.Length > 1) + throw new InvalidOperationException($"{spec.Name} folder is ambiguous, please delete one: {string.Join(", ", moduleDirs)}"); + if (moduleDirs.Length == 0) + { + cmdlet?.WriteDebug($"{spec}: Skipping PSModulePath {modulePath} - Does not have this module."); + continue; + } + + var moduleBaseDir = moduleDirs[0]; + var candidatePaths = new List<(Version version, string path)>(); + var manifestName = $"{spec.Name}.psd1"; + + var required = spec.Required; + if (required != null) + { + var moduleVersion = ResolveFolderVersion(required); + var moduleFolder = Path.Combine(moduleBaseDir, moduleVersion.ToString()); + var manifestPath = Path.Combine(moduleFolder, manifestName); + if (Directory.Exists(moduleFolder)) + candidatePaths.Add((moduleVersion, moduleFolder)); + } + else + { + // Enumerate versioned sub-folders + foreach (var folder in Directory.GetDirectories(moduleBaseDir)) + { + var leafName = Path.GetFileName(folder); + if (!Version.TryParse(leafName, out var version)) + { + cmdlet?.WriteDebug($"Could not parse {folder} in {moduleBaseDir} as a valid version."); + continue; + } + + if (spec.Max != null && version > spec.Max.Version) + { + cmdlet?.WriteDebug($"{spec}: Skipping {folder} - above the upper bound"); + continue; + } + + if (spec.Min != null) + { + var originalParts = (spec.Min.OriginalVersion ?? "").Split('-')[0]; + var minVersion = Version.TryParse(originalParts, out var parsedBase) && parsedBase.Revision == -1 + ? parsedBase + : spec.Min.Version; + if (version < minVersion) + { + cmdlet?.WriteDebug($"{spec}: Skipping {folder} - {version} is below the lower bound of {minVersion}"); + continue; + } + } + + candidatePaths.Add((version, folder)); + } + + // Sort descending by version + candidatePaths.Sort((a, b) => b.version.CompareTo(a.version)); + } + + // Classic module fallback + if (candidatePaths.Count == 0) + { + var classicManifests = Directory.GetFiles(moduleBaseDir, manifestName, + new EnumerationOptions { MatchCasing = MatchCasing.CaseInsensitive }); + if (classicManifests.Length > 1) + throw new InvalidOperationException($"{moduleBaseDir} manifest is ambiguous: {string.Join(", ", classicManifests)}"); + if (classicManifests.Length == 1) + { + var classicManifestPath = classicManifests[0]; + var classicData = ModuleManifestReader.ImportModuleManifest(classicManifestPath, cmdlet); + if (Version.TryParse(classicData["ModuleVersion"]?.ToString() ?? "", out var classicVersion)) + { + cmdlet?.WriteDebug($"{spec}: Found classic module {classicVersion} at {moduleBaseDir}"); + candidatePaths.Add((classicVersion, moduleBaseDir)); + } + } + } + + if (candidatePaths.Count == 0) + { + cmdlet?.WriteDebug($"{spec}: Skipping PSModulePath {modulePath} - No installed versions matched the spec."); + continue; + } + + foreach (var (version, folder) in candidatePaths) + { + if (File.Exists(Path.Combine(folder, ".incomplete"))) + { + cmdlet?.WriteWarning($"{spec}: Incomplete installation detected at {folder}. Deleting and ignoring."); + Directory.Delete(folder, true); + continue; + } + + var manifests = Directory.GetFiles(folder, manifestName, + new EnumerationOptions { MatchCasing = MatchCasing.CaseInsensitive }); + + if (manifests.Length > 1) + throw new InvalidOperationException($"{folder} manifest is ambiguous: {string.Join(", ", manifests)}"); + if (manifests.Length == 0) + { + cmdlet?.WriteWarning($"{spec}: Found candidate folder {folder} but no {manifestName} manifest found. This may be a corrupt module."); + continue; + } + + ModuleFastInfo manifestCandidate; + try + { + manifestCandidate = ModuleManifestReader.ConvertFromModuleManifest(manifests[0], cmdlet); + } + catch (Exception ex) + { + cmdlet?.WriteWarning($"{spec}: Failed to read manifest at {manifests[0]}: {ex.Message}"); + continue; + } + + if (spec.Guid != Guid.Empty && manifestCandidate.Guid != spec.Guid) + { + cmdlet?.WriteWarning($"{spec}: Module at {folder} GUID {manifestCandidate.Guid} does not match spec GUID {spec.Guid}."); + continue; + } + + var candidateVersion = manifestCandidate.ModuleVersion; + + if (spec.SatisfiedBy(candidateVersion, strictSemVer)) + { + if (update && spec.Max != candidateVersion) + { + cmdlet?.WriteDebug($"{spec}: Skipping {candidateVersion} because -Update was specified and version does not exactly meet upper bound."); + if (bestCandidates != null && + (!bestCandidates.TryGetValue(spec, out var existing) || + manifestCandidate.ModuleVersion > existing.ModuleVersion)) + { + cmdlet?.WriteDebug($"{spec}: ⬆️ New Best Candidate Version {manifestCandidate.ModuleVersion}"); + bestCandidates[spec] = manifestCandidate; + } + continue; + } + return manifestCandidate; + } + } + } + + return null; + } +} \ No newline at end of file diff --git a/Source/ModuleFast/ModuleFast.csproj b/Source/ModuleFast/ModuleFast.csproj new file mode 100644 index 0000000..2667f93 --- /dev/null +++ b/Source/ModuleFast/ModuleFast.csproj @@ -0,0 +1,24 @@ + + + net8.0 + enable + enable + ModuleFast + ModuleFast + true + false + false + $(MSBuildProjectDirectory)\..\..\Build\ + + + + + + + + + + + + + diff --git a/Source/ModuleFast/ModuleFastCache.cs b/Source/ModuleFast/ModuleFastCache.cs new file mode 100644 index 0000000..beb0761 --- /dev/null +++ b/Source/ModuleFast/ModuleFastCache.cs @@ -0,0 +1,13 @@ +using System.Collections.Concurrent; + +namespace ModuleFast; + +public class ModuleFastCache +{ + private readonly ConcurrentDictionary> _cache = new(StringComparer.OrdinalIgnoreCase); + public static readonly ModuleFastCache Instance = new(); + + public Task? Get(string key) => _cache.TryGetValue(key, out var v) ? v : null; + public void Set(string key, Task value) => _cache[key] = value; + public void Clear() => _cache.Clear(); +} \ No newline at end of file diff --git a/Source/ModuleFast/ModuleFastClient.cs b/Source/ModuleFast/ModuleFastClient.cs new file mode 100644 index 0000000..9c2d692 --- /dev/null +++ b/Source/ModuleFast/ModuleFastClient.cs @@ -0,0 +1,37 @@ +using System.Management.Automation; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; + +namespace ModuleFast; + +public static class ModuleFastClient +{ + public static HttpClient Create(PSCredential? credential = null, int timeoutSeconds = 30) + { + AppContext.SetSwitch("System.Net.SocketsHttpHandler.Http3Support", true); + var handler = new SocketsHttpHandler + { + MaxConnectionsPerServer = 10, + InitialHttp2StreamWindowSize = 16777216, + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli + }; + var client = new HttpClient(handler) + { + Timeout = TimeSpan.FromSeconds(timeoutSeconds) + }; + client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher; + client.DefaultRequestHeaders.UserAgent.TryParseAdd("ModuleFast (github.com/JustinGrote/ModuleFast)"); + if (credential != null) + client.DefaultRequestHeaders.Authorization = ToAuthHeader(credential); + return client; + } + + public static AuthenticationHeaderValue ToAuthHeader(PSCredential credential) + { + var token = Convert.ToBase64String( + Encoding.UTF8.GetBytes($"{credential.UserName}:{credential.GetNetworkCredential().Password}")); + return new AuthenticationHeaderValue("Basic", token); + } +} \ No newline at end of file diff --git a/Source/ModuleFast/ModuleFastInfo.cs b/Source/ModuleFast/ModuleFastInfo.cs new file mode 100644 index 0000000..6080e9b --- /dev/null +++ b/Source/ModuleFast/ModuleFastInfo.cs @@ -0,0 +1,59 @@ +using System; +using System.Management.Automation; + +using Microsoft.PowerShell.Commands; + +using NuGet.Versioning; + +namespace ModuleFast; + +/// +/// Information about a module, whether local or remote. +/// +public sealed class ModuleFastInfo : IComparable +{ + public string Name { get; } + public NuGetVersion ModuleVersion { get; set; } + public Uri Location { get; set; } + public bool IsLocal => Location?.IsFile ?? false; + public Guid Guid { get; set; } + + public bool PreRelease => ModuleVersion.IsPrerelease || ModuleVersion.HasMetadata; + + public ModuleFastInfo(string name, NuGetVersion version, Uri location) + { + Name = name; + ModuleVersion = version; + Location = location; + Guid = Guid.Empty; + } + + public ModuleFastInfo(string name, string version, string location) + : this(name, NuGetVersion.Parse(version), new Uri(location)) { } + + public static implicit operator ModuleSpecification(ModuleFastInfo info) => + new(new System.Collections.Hashtable + { + ["ModuleName"] = info.Name, + ["RequiredVersion"] = info.ModuleVersion.Version + }); + + public override string ToString() => $"{Name}({ModuleVersion})"; + + public string ToUniqueString() => $"{Name}-{ModuleVersion}-{Location}"; + + public override int GetHashCode() => ToUniqueString().GetHashCode(); + + public override bool Equals(object? obj) => + obj is ModuleFastInfo other && GetHashCode() == other.GetHashCode(); + + public int CompareTo(object? other) + { + if (other is not ModuleFastInfo otherInfo) + return ToUniqueString().CompareTo(other?.ToString()); + + if (Equals(otherInfo)) return 0; + if (Name != otherInfo.Name) return string.Compare(Name, otherInfo.Name, StringComparison.Ordinal); + return ModuleVersion.CompareTo(otherInfo.ModuleVersion); + } +} \ No newline at end of file diff --git a/Source/ModuleFast/ModuleFastInstaller.cs b/Source/ModuleFast/ModuleFastInstaller.cs new file mode 100644 index 0000000..7d47b20 --- /dev/null +++ b/Source/ModuleFast/ModuleFastInstaller.cs @@ -0,0 +1,168 @@ +using System.IO.Compression; +using System.Management.Automation; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +using NuGet.Versioning; + +namespace ModuleFast; + +public class ModuleFastInstaller +{ + private readonly HttpClient _httpClient; + + public ModuleFastInstaller(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task> InstallModulesAsync( + IEnumerable modules, + string destination, + bool update, + CancellationToken ct, + PSCmdlet? cmdlet = null) + { + var tasks = modules.Select(m => InstallSingleAsync(m, destination, update, ct, cmdlet)); + var results = await Task.WhenAll(tasks).ConfigureAwait(false); + return results.Where(r => r != null).Cast().ToList(); + } + + private async Task InstallSingleAsync( + ModuleFastInfo module, + string destination, + bool update, + CancellationToken ct, + PSCmdlet? cmdlet) + { + var installPath = Path.Combine(destination, module.Name, + LocalModuleFinder.ResolveFolderVersion(module.ModuleVersion).ToString()); + var installIndicatorPath = Path.Combine(installPath, ".incomplete"); + + if (File.Exists(installIndicatorPath)) + { + cmdlet?.WriteWarning($"{module}: Incomplete installation found at {installPath}. Will delete and retry."); + Directory.Delete(installPath, true); + } + + if (Directory.Exists(installPath)) + { + var existingManifestPath = Path.Combine(installPath, $"{module.Name}.psd1"); + if (!File.Exists(existingManifestPath)) + throw new InvalidOperationException($"{module}: Existing module folder found at {installPath} but the manifest could not be found."); + + var existingManifestData = ModuleManifestReader.ImportModuleManifest(existingManifestPath, cmdlet); + var existingVersionStr = existingManifestData["ModuleVersion"]?.ToString() ?? "0.0.0"; + var prerelease = (existingManifestData["PrivateData"] as System.Collections.Hashtable)?["PSData"] is System.Collections.Hashtable psData + ? psData["Prerelease"]?.ToString() : null; + + Version.TryParse(existingVersionStr, out var evBase); + var existingVersion = new NuGetVersion(evBase ?? new Version(0, 0), prerelease); + + if (module.ModuleVersion == existingVersion) + { + if (update) + { + cmdlet?.WriteDebug($"{module}: Existing module found at {installPath} and version matches. -Update was specified so assuming same version and skipping."); + return null; + } + else + { + throw new NotImplementedException($"{module}: Existing module found at {installPath} and version {existingVersion} is the same. This is probably a bug. Use -Update to override."); + } + } + + if (module.ModuleVersion < existingVersion) + throw new NotSupportedException($"{module}: Existing module found at {installPath} and its version {existingVersion} is newer than the requested version {module.ModuleVersion}. If you wish to continue, remove the existing folder or modify your specification."); + + cmdlet?.WriteWarning($"{module}: Planned version {module.ModuleVersion} is newer than existing version {existingVersion} so we will overwrite."); + Directory.Delete(installPath, true); + } + + cmdlet?.WriteVerbose($"{module}: Downloading from {module.Location}"); + if (module.Location == null) + throw new InvalidOperationException($"{module}: No Download Link found. This is a bug."); + + await using var stream = await _httpClient.GetStreamAsync(module.Location, ct).ConfigureAwait(false); + + Directory.CreateDirectory(installPath); + File.WriteAllText(installIndicatorPath, ""); + + using var zip = new ZipArchive(stream, ZipArchiveMode.Read); + zip.ExtractToDirectory(installPath, overwriteFiles: true); + + // Fast scan for manifest version + var manifestPath = Path.Combine(installPath, $"{module.Name}.psd1"); + var moduleManifestVersion = ModuleManifestReader.TryReadModuleVersionFast(manifestPath); + + if (moduleManifestVersion == null) + { + cmdlet?.WriteWarning($"{module}: Could not detect the module manifest version. This module may not install properly if it has trailing zeros."); + } + else + { + var originalModuleVersion = Path.GetFileName(installPath); + if (originalModuleVersion != moduleManifestVersion.ToString()) + { + cmdlet?.WriteDebug($"{module}: Module Manifest Version {moduleManifestVersion} differs from package version {originalModuleVersion}, moving..."); + var installPathRoot = Path.GetDirectoryName(installPath)!; + var newInstallPath = Path.Combine(installPathRoot, moduleManifestVersion.ToString()); + + if (Directory.Exists(newInstallPath)) + Directory.Delete(newInstallPath, true); + + Directory.Move(installPath, newInstallPath); + installPath = newInstallPath; + + // Update indicator path + installIndicatorPath = Path.Combine(installPath, ".incomplete"); + File.WriteAllText(Path.Combine(installPath, ".originalModuleVersion"), originalModuleVersion); + + module.ModuleVersion = new NuGetVersion(moduleManifestVersion.ToString()); + } + else + { + cmdlet?.WriteDebug($"{module}: Module Manifest version matches the expected version."); + } + } + + // Verify GUID if specified + if (module.Guid != Guid.Empty) + { + cmdlet?.WriteDebug($"{module}: GUID was specified. Verifying manifest."); + var manifestData = ModuleManifestReader.ImportModuleManifest( + Path.Combine(installPath, $"{module.Name}.psd1"), cmdlet); + if (!Guid.TryParse(manifestData["GUID"]?.ToString() ?? "", out var manifestGuid) || + manifestGuid != module.Guid) + { + Directory.Delete(installPath, true); + throw new InvalidOperationException( + $"{module}: The installed package GUID does not match. Expected {module.Guid} but found {manifestGuid} in {manifestPath}."); + } + } + + // Clean up NuGet files + cmdlet?.WriteDebug($"Cleanup Nuget Files in {installPath}"); + if (string.IsNullOrEmpty(installPath)) + throw new InvalidOperationException("ModuleDestination was not set. This is a bug."); + + foreach (var item in Directory.GetFileSystemEntries(installPath)) + { + var name = Path.GetFileName(item); + if (name is "_rels" or "package" or "[Content_Types].xml" || + name.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase)) + { + if (File.Exists(item)) File.Delete(item); + else if (Directory.Exists(item)) Directory.Delete(item, true); + } + } + + // Remove .incomplete marker + if (File.Exists(installIndicatorPath)) + File.Delete(installIndicatorPath); + + module.Location = new Uri(installPath); + return module; + } +} \ No newline at end of file diff --git a/Source/ModuleFast/ModuleFastPlanner.cs b/Source/ModuleFast/ModuleFastPlanner.cs new file mode 100644 index 0000000..480406f --- /dev/null +++ b/Source/ModuleFast/ModuleFastPlanner.cs @@ -0,0 +1,321 @@ +using System.Collections.Generic; +using System.Management.Automation; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +using NuGet.Versioning; + +namespace ModuleFast; + +public class ModuleFastPlanner +{ + private readonly HttpClient _httpClient; + private readonly string _source; + private static readonly JsonSerializerOptions _jsonOpts = new() { PropertyNameCaseInsensitive = true }; + + public ModuleFastPlanner(HttpClient httpClient, string source) + { + _httpClient = httpClient; + _source = source; + } + + public async Task> GetPlanAsync( + IEnumerable specs, + string[] modulePaths, + bool update, + bool prerelease, + bool strictSemVer, + bool destinationOnly, + CancellationToken ct, + PSCmdlet? cmdlet = null) + { + var modulesToInstall = new HashSet(); + var bestLocalCandidates = new Dictionary(); + var pendingTasks = new Dictionary, ModuleFastSpec>(); + + // Seed initial tasks + foreach (var spec in specs) + { + cmdlet?.WriteVerbose($"{spec}: Evaluating Module Specification"); + var localMatch = LocalModuleFinder.FindLocalModule(spec, modulePaths, update, bestLocalCandidates, strictSemVer, cmdlet); + if (localMatch != null && !update) + { + cmdlet?.WriteDebug($"{localMatch}: 🎯 FOUND satisfying version {localMatch.ModuleVersion} at {localMatch.Location}. Skipping remote search."); + continue; + } + cmdlet?.WriteDebug($"{spec}: 🔍 No installed versions matched. Will check remotely."); + var task = GetModuleInfoAsync(spec.Name, _source, ct); + pendingTasks[task] = spec; + } + + while (pendingTasks.Count > 0) + { + var completed = await Task.WhenAny(pendingTasks.Keys).ConfigureAwait(false); + var currentSpec = pendingTasks[completed]; + pendingTasks.Remove(completed); + + if (currentSpec.Guid != Guid.Empty) + cmdlet?.WriteWarning($"{currentSpec}: A GUID constraint was found. GUIDs will only be verified after installation."); + + cmdlet?.WriteDebug($"{currentSpec}: Processing Response"); + + string json; + try + { + json = await completed.ConfigureAwait(false); + } + catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + throw new InvalidOperationException($"{currentSpec}: module was not found in the {_source} repository. Check the spelling and try again."); + } + catch (HttpRequestException ex) + { + throw new InvalidOperationException($"{currentSpec}: Failed to fetch module from {_source}. Error: {ex.Message}", ex); + } + + RegistrationResponse response; + try + { + response = JsonSerializer.Deserialize(json, _jsonOpts) + ?? throw new InvalidDataException($"{currentSpec}: Invalid response from {_source}"); + } + catch (JsonException ex) + { + throw new InvalidDataException($"{currentSpec}: Invalid JSON response from {_source}: {ex.Message}", ex); + } + + if (response.Count == 0 && response.Items.Length == 0) + throw new InvalidDataException($"{currentSpec}: invalid result received from {_source}."); + + var selectedEntry = FindBestEntry(response, currentSpec, prerelease, strictSemVer, cmdlet); + if (selectedEntry == null) + { + // Try non-inlined pages + selectedEntry = await FetchBestEntryFromPagesAsync(response, currentSpec, prerelease, strictSemVer, ct, cmdlet) + .ConfigureAwait(false); + } + + if (selectedEntry == null) + throw new InvalidOperationException($"{currentSpec}: a matching module was not found in the {_source} repository that satisfies the version constraints. You may need to specify -PreRelease or adjust your version constraints."); + + if (string.IsNullOrEmpty(selectedEntry.PackageContent)) + throw new InvalidDataException($"No package location found for {currentSpec}. This is a bug."); + + if (selectedEntry.Tags != null && Array.Exists(selectedEntry.Tags, t => t == "ItemType:Script")) + throw new NotImplementedException($"{currentSpec}: Script installations are currently not supported."); + + var selectedModule = new ModuleFastInfo( + selectedEntry.Id, + NuGetVersion.Parse(selectedEntry.Version), + new Uri(selectedEntry.PackageContent)); + + if (currentSpec.Guid != Guid.Empty) + selectedModule.Guid = currentSpec.Guid; + + // If -Update was specified, check if best local candidate matches + if (update && bestLocalCandidates.TryGetValue(currentSpec, out var bestLocal) && + bestLocal.ModuleVersion == selectedModule.ModuleVersion) + { + cmdlet?.WriteDebug($"{selectedModule}: ✅ -Update specified and best remote matches local. Skipping."); + continue; + } + + if (!modulesToInstall.Add(selectedModule)) + { + cmdlet?.WriteDebug($"{selectedModule} already exists in the install plan. Skipping..."); + continue; + } + + cmdlet?.WriteVerbose($"{selectedModule}: Added to install plan"); + + // Queue dependency tasks + var allDeps = selectedEntry.DependencyGroups? + .SelectMany(g => g.Dependencies ?? []) ?? []; + + foreach (var dep in allDeps) + { + var depRange = string.IsNullOrWhiteSpace(dep.Range) + ? VersionRange.All + : VersionRange.Parse(dep.Range); + var depSpec = new ModuleFastSpec(dep.Id, depRange); + + // Check if already satisfied by planned installs + var moduleNames = new HashSet(modulesToInstall.Select(m => m.Name), StringComparer.OrdinalIgnoreCase); + if (moduleNames.Contains(depSpec.Name)) + { + var existing = modulesToInstall.Where(m => string.Equals(m.Name, depSpec.Name, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(m => m.ModuleVersion) + .FirstOrDefault(); + if (existing != null && depSpec.SatisfiedBy(existing.ModuleVersion, strictSemVer)) + { + cmdlet?.WriteDebug($"Dependency {depSpec} satisfied by existing planned install {existing}"); + continue; + } + } + + var depLocal = LocalModuleFinder.FindLocalModule(depSpec, modulePaths, update, bestLocalCandidates, strictSemVer, cmdlet); + if (depLocal != null) + { + cmdlet?.WriteDebug($"FOUND local module {depLocal.Name} {depLocal.ModuleVersion} satisfies {depSpec}. Skipping..."); + continue; + } + + cmdlet?.WriteDebug($"{currentSpec}: Fetching dependency {depSpec}"); + var depTask = GetModuleInfoAsync(depSpec.Name, _source, ct); + pendingTasks[depTask] = depSpec; + } + } + + return modulesToInstall; + } + + private CatalogEntry? FindBestEntry( + RegistrationResponse response, + ModuleFastSpec spec, + bool prerelease, + bool strictSemVer, + PSCmdlet? cmdlet) + { + var inlinedLeaves = response.Items + .Where(p => p.Items != null) + .SelectMany(p => p.Items!) + .ToArray(); + + if (inlinedLeaves.Length == 0) return null; + + // Normalize packageContent + foreach (var leaf in inlinedLeaves) + { + if (!string.IsNullOrEmpty(leaf.PackageContent) && string.IsNullOrEmpty(leaf.CatalogEntry.PackageContent)) + leaf.CatalogEntry.PackageContent = leaf.PackageContent; + } + + var entries = inlinedLeaves.Select(l => l.CatalogEntry).ToArray(); + if (entries.Length == 0) return null; + + var versions = new SortedSet( + entries.Select(e => NuGetVersion.TryParse(e.Version, out var v) ? v : null).Where(v => v != null)!); + + foreach (var candidate in versions.Reverse()) + { + if ((candidate.IsPrerelease || candidate.HasMetadata) && !(spec.PreRelease || prerelease)) + { + cmdlet?.WriteDebug($"{spec}: skipping candidate {candidate} - prerelease not requested."); + continue; + } + if (spec.SatisfiedBy(candidate, strictSemVer)) + { + cmdlet?.WriteDebug($"{spec}: Found satisfying version {candidate} in inlined index."); + return entries.First(e => e.Version == candidate.OriginalVersion || + NuGetVersion.TryParse(e.Version, out var v) && v == candidate); + } + } + return null; + } + + private async Task FetchBestEntryFromPagesAsync( + RegistrationResponse response, + ModuleFastSpec spec, + bool prerelease, + bool strictSemVer, + CancellationToken ct, + PSCmdlet? cmdlet) + { + cmdlet?.WriteDebug($"{spec}: not found in inlined index. Determining appropriate page(s) to query."); + + var pages = response.Items + .Where(p => p.Items == null) + .Where(p => + { + if (string.IsNullOrEmpty(p.Lower) || string.IsNullOrEmpty(p.Upper)) return true; + if (!NuGetVersion.TryParse(p.Lower, out var lower) || !NuGetVersion.TryParse(p.Upper, out var upper)) return true; + var pageRange = new VersionRange(lower, true, upper, true); + return spec.Overlap(pageRange); + }) + .OrderByDescending(p => NuGetVersion.TryParse(p.Upper, out var v) ? v : null) + .ToArray(); + + if (pages.Length == 0) + throw new InvalidOperationException($"{spec}: a matching module was not found in the {_source} repository that satisfies the requested version constraints. You may need to specify -PreRelease or adjust your version constraints."); + + cmdlet?.WriteDebug($"{spec}: Found {pages.Length} additional pages to query."); + + foreach (var page in pages) + { + var pageJson = await GetCachedStringAsync(page.Id, ct).ConfigureAwait(false); + RegistrationPage pageData; + try + { + pageData = JsonSerializer.Deserialize(pageJson, _jsonOpts) + ?? throw new InvalidDataException("Invalid page response"); + } + catch (JsonException) + { + // Some servers return RegistrationResponse for page URLs too + var pageResponse = JsonSerializer.Deserialize(pageJson, _jsonOpts); + pageData = pageResponse?.Items?.FirstOrDefault() ?? new RegistrationPage(); + } + + if (pageData.Items == null) continue; + + foreach (var leaf in pageData.Items) + { + if (!string.IsNullOrEmpty(leaf.PackageContent) && string.IsNullOrEmpty(leaf.CatalogEntry.PackageContent)) + leaf.CatalogEntry.PackageContent = leaf.PackageContent; + } + + var entries = pageData.Items.Select(l => l.CatalogEntry).ToArray(); + var versions = new SortedSet( + entries.Select(e => NuGetVersion.TryParse(e.Version, out var v) ? v : null).Where(v => v != null)!); + + foreach (var candidate in versions.Reverse()) + { + if ((candidate.IsPrerelease || candidate.HasMetadata) && !(spec.PreRelease || prerelease)) + continue; + if (spec.SatisfiedBy(candidate, strictSemVer)) + { + cmdlet?.WriteDebug($"{spec}: Found satisfying version {candidate} in additional pages."); + return entries.First(e => NuGetVersion.TryParse(e.Version, out var v) && v == candidate); + } + } + } + return null; + } + + private async Task GetModuleInfoAsync(string name, string endpoint, CancellationToken ct) + { + var registrationBase = await GetRegistrationBaseAsync(endpoint, ct).ConfigureAwait(false); + var uri = $"{registrationBase.TrimEnd('/')}/{name.ToLowerInvariant()}/index.json"; + return await GetCachedStringAsync(uri, ct).ConfigureAwait(false); + } + + private async Task GetRegistrationBaseAsync(string endpoint, CancellationToken ct) + { + var indexJson = await GetCachedStringAsync(endpoint, ct).ConfigureAwait(false); + var index = JsonSerializer.Deserialize(indexJson, _jsonOpts) + ?? throw new InvalidDataException("Invalid registration index from " + endpoint); + + var registrationBase = index.Resources + .Where(r => r.Type.Contains("RegistrationsBaseUrl")) + .OrderByDescending(r => r.Type) + .Select(r => r.Id) + .FirstOrDefault() + ?? throw new InvalidDataException($"Could not find RegistrationsBaseUrl in index from {endpoint}"); + + return registrationBase; + } + + private Task GetCachedStringAsync(string uri, CancellationToken ct) + { + var cached = ModuleFastCache.Instance.Get(uri); + if (cached != null) + return cached; + + var task = _httpClient.GetStringAsync(uri, ct); + ModuleFastCache.Instance.Set(uri, task); + return task; + } +} \ No newline at end of file diff --git a/Source/ModuleFast/ModuleFastSpec.cs b/Source/ModuleFast/ModuleFastSpec.cs new file mode 100644 index 0000000..e8ef216 --- /dev/null +++ b/Source/ModuleFast/ModuleFastSpec.cs @@ -0,0 +1,326 @@ +using System; +using System.Collections.Generic; +using System.Management.Automation; + +using Microsoft.PowerShell.Commands; + +using NuGet.Versioning; + +namespace ModuleFast; + +/// +/// Represents a module specification for ModuleFast, supporting NuGet version ranges and prerelease. +/// +public sealed class ModuleFastSpec : IComparable, IEquatable +{ + private readonly string _name; + private readonly Guid _guid; + private readonly VersionRange _versionRange; + private readonly bool _preReleaseName; + + // --- Properties --- + + public string Name => _name; + public Guid Guid => _guid; + public VersionRange VersionRange => _versionRange; + + public NuGetVersion? Min => _versionRange.MinVersion; + public NuGetVersion? Max => _versionRange.MaxVersion; + + public NuGetVersion? Required => + Min != null && Max != null && Min == Max ? Min : null; + + public bool PreRelease => + (_versionRange.MinVersion?.IsPrerelease ?? false) || + (_versionRange.MaxVersion?.IsPrerelease ?? false) || + (_versionRange.MinVersion?.HasMetadata ?? false) || + (_versionRange.MaxVersion?.HasMetadata ?? false) || + _preReleaseName; + + // ModuleSpecification compatible helpers (used by op_Implicit) + public Version? RequiredVersion => Required?.Version; + public Version? Version => Min?.Version; + public Version? MaximumVersion => Max?.Version; + + // --- Constructors --- + + public ModuleFastSpec(string name) + { + if (string.IsNullOrEmpty(name)) + throw new ArgumentException("Name is required", nameof(name)); + + // Try ModuleSpecification hashtable-like string first + if (ModuleSpecification.TryParse(name, out var moduleSpec)) + { + (_name, _versionRange, _guid, _preReleaseName) = InitFromModuleSpec(moduleSpec!); + return; + } + + if (name.Contains("@{", StringComparison.Ordinal)) + throw new ArgumentException($"Cannot convert '{name}' to a ModuleFastSpec: not valid ModuleSpecification syntax.", nameof(name)); + + // Prerelease flag + bool preReleaseName = false; + if (name.StartsWith('!') || name.EndsWith('!')) + { + preReleaseName = true; + name = name.Trim('!'); + } + + string moduleName; + VersionRange range; + + if (name.Contains(">=", StringComparison.Ordinal)) + { + var parts = name.Split(">=", 2); + moduleName = parts[0]; + range = NuGetVersion.TryParse(parts[1], out var lower) + ? new VersionRange(lower, true) + : throw new ArgumentException($"Invalid version '{parts[1]}'"); + } + else if (name.Contains("<=", StringComparison.Ordinal)) + { + var parts = name.Split("<=", 2); + moduleName = parts[0]; + range = NuGetVersion.TryParse(parts[1], out var upper) + ? new VersionRange(null, false, upper, true) + : throw new ArgumentException($"Invalid version '{parts[1]}'"); + } + else if (name.Contains('=')) + { + var parts = name.Split('=', 2); + moduleName = parts[0]; + range = VersionRange.Parse($"[{parts[1]}]"); + } + else if (name.Contains(':')) + { + var parts = name.Split(':', 2); + moduleName = parts[0]; + range = VersionRange.Parse(parts[1]); + } + else if (name.Contains('>')) + { + var parts = name.Split('>', 2); + moduleName = parts[0]; + range = NuGetVersion.TryParse(parts[1], out var lowerExcl) + ? new VersionRange(lowerExcl, false) + : throw new ArgumentException($"Invalid version '{parts[1]}'"); + } + else if (name.Contains('<')) + { + var parts = name.Split('<', 2); + moduleName = parts[0]; + range = NuGetVersion.TryParse(parts[1], out var upperExcl) + ? new VersionRange(null, false, upperExcl, false) + : throw new ArgumentException($"Invalid version '{parts[1]}'"); + } + else + { + moduleName = name; + range = VersionRange.All; + } + + _name = moduleName; + _versionRange = range; + _guid = System.Guid.Empty; + _preReleaseName = preReleaseName; + } + + public ModuleFastSpec(string name, string requiredVersion) + : this(name, requiredVersion, System.Guid.Empty.ToString()) { } + + public ModuleFastSpec(string name, string requiredVersion, string guid) + { + if (string.IsNullOrEmpty(name)) throw new ArgumentException("Name is required", nameof(name)); + _name = name.Trim('!'); + _preReleaseName = name.StartsWith('!') || name.EndsWith('!'); + _versionRange = VersionRange.Parse($"[{requiredVersion}]"); + _guid = System.Guid.TryParse(guid, out var g) ? g : System.Guid.Empty; + } + + public ModuleFastSpec(string name, VersionRange range) + { + if (string.IsNullOrEmpty(name)) throw new ArgumentException("Name is required", nameof(name)); + _name = name.Trim('!'); + _preReleaseName = name.StartsWith('!') || name.EndsWith('!'); + _versionRange = range ?? VersionRange.All; + _guid = System.Guid.Empty; + } + + public ModuleFastSpec(ModuleSpecification spec) + { + (_name, _versionRange, _guid, _preReleaseName) = InitFromModuleSpec(spec); + } + + // --- Private helpers --- + + private static (string name, VersionRange range, Guid guid, bool preReleaseName) + InitFromModuleSpec(ModuleSpecification spec) + { + string? minStr = spec.RequiredVersion?.ToString() ?? spec.Version?.ToString(); + string? maxStr = spec.RequiredVersion?.ToString() ?? spec.MaximumVersion?.ToString(); + + var range = new VersionRange( + string.IsNullOrEmpty(minStr) ? null : NuGetVersion.Parse(minStr), + true, + string.IsNullOrEmpty(maxStr) ? null : NuGetVersion.Parse(maxStr), + true, + null, + $"ModuleSpecification: {spec}" + ); + + var guid = spec.Guid ?? System.Guid.Empty; + return (spec.Name, range, guid, false); + } + + // --- Methods --- + + public bool SatisfiedBy(System.Version version) => + SatisfiedBy(new NuGetVersion(version), false); + + public bool SatisfiedBy(NuGetVersion version) => + SatisfiedBy(version, false); + + /// + /// Checks if this spec is satisfied by a given version. + /// When strictSemVer=false (default), a prerelease of an exclusive upper bound is excluded. + /// E.g. [1.0.0,2.0.0) will NOT match 2.0.0-alpha1 (non-strict), matching user expectation. + /// + public bool SatisfiedBy(NuGetVersion version, bool strictSemVer) + { + var range = _versionRange; + bool strictSatisfies = range.IsFloating + ? range.Float!.Satisfies(version) + : range.Satisfies(version); + + if (strictSemVer) + return strictSatisfies; + + if (range.MaxVersion == null) + return strictSatisfies; + + var max = range.MaxVersion; + var min = range.MinVersion; + + if (version.IsPrerelease && + !range.IsMaxInclusive && + !max.IsPrerelease && + max.Major == version.Major && + max.Minor == version.Minor && + max.Patch == version.Patch) + { + // Special case: (3.0.0-alpha,3.0.0-beta) — min and max share same M.m.p and min is prerelease + if (min != null && + min.Major == max.Major && + min.Minor == max.Minor && + min.Patch == max.Patch && + min.IsPrerelease) + { + return strictSatisfies; + } + return false; + } + + return strictSatisfies; + } + + public bool Overlap(ModuleFastSpec other) => Overlap(other._versionRange); + + public bool Overlap(VersionRange other) + { + var subset = VersionRange.CommonSubSet(new List { _versionRange, other }); + return !subset.Equals(VersionRange.None); + } + + // --- Interface implementations --- + + public bool Equals(ModuleFastSpec? other) => + other != null && GetHashCode() == other.GetHashCode(); + + public override bool Equals(object? obj) => + obj is ModuleFastSpec other && Equals(other); + + public override int GetHashCode() => ToString().GetHashCode(); + + public int CompareTo(object? other) + { + if (Equals(other)) return 0; + + NuGetVersion? version; + if (other is ModuleFastSpec otherSpec) + { + version = IsRequiredVersion(otherSpec._versionRange) + ? otherSpec._versionRange.MaxVersion + : throw new NotSupportedException($"ModuleFastSpec {other} has a version range, it must be a single required version e.g. '[1.5.0]'"); + } + else if (other is VersionRange otherRange) + { + version = IsRequiredVersion(otherRange) + ? otherRange.MaxVersion + : throw new NotSupportedException($"ModuleFastSpec {other} has a version range, it must be a single required version e.g. '[1.5.0]'"); + } + else + { + return ToString().CompareTo(other?.ToString()); + } + + if (_versionRange.Satisfies(version!)) return 0; + if (_versionRange.MinVersion != null && _versionRange.MinVersion > version!) return 1; + if (_versionRange.MaxVersion != null && _versionRange.MaxVersion < version!) return -1; + throw new InvalidOperationException("Could not compare. This is a bug."); + } + + private static bool IsRequiredVersion(VersionRange version) => + version.MinVersion == version.MaxVersion && + version.HasLowerAndUpperBounds && + version.IsMinInclusive && + version.IsMaxInclusive; + + public override string ToString() + { + string guid = _guid != System.Guid.Empty ? $" [{_guid}]" : ""; + string versionRange; + if (_versionRange.ToString() == "(, )") + { + versionRange = ""; + } + else if (_versionRange.MaxVersion != null && _versionRange.MaxVersion == _versionRange.MinVersion) + { + versionRange = $"({_versionRange.MinVersion})"; + } + else + { + versionRange = $" {_versionRange}"; + } + return $"{_name}{guid}{versionRange}"; + } + + // --- Implicit conversion --- + + public static implicit operator ModuleSpecification(ModuleFastSpec spec) + { + var props = new System.Collections.Hashtable + { + ["ModuleName"] = spec.Name + }; + + if (spec.Guid != System.Guid.Empty) + props["Guid"] = spec.Guid; + + if (spec.Required != null) + { + props["RequiredVersion"] = spec.Required.Version; + } + else if (spec.Min != null || spec.Max != null) + { + if (spec.Min != null) props["ModuleVersion"] = spec.Min.Version; + if (spec.Max != null) props["MaximumVersion"] = spec.Max.Version; + } + else + { + props["ModuleVersion"] = new System.Version(0, 0); + } + + return new ModuleSpecification(props); + } +} \ No newline at end of file diff --git a/Source/ModuleFast/ModuleManifestReader.cs b/Source/ModuleFast/ModuleManifestReader.cs new file mode 100644 index 0000000..99aa765 --- /dev/null +++ b/Source/ModuleFast/ModuleManifestReader.cs @@ -0,0 +1,103 @@ +using System.Collections; +using System.Management.Automation; +using System.Management.Automation.Runspaces; + +using NuGet.Versioning; + +namespace ModuleFast; + +public static class ModuleManifestReader +{ + /// + /// Imports a module manifest (psd1), handling dynamic expression manifests as well. + /// + public static Hashtable ImportModuleManifest(string path, PSCmdlet? cmdlet = null) + { + try + { + using var ps = PowerShell.Create(RunspaceMode.CurrentRunspace); + ps.AddCommand("Import-PowerShellDataFile").AddParameter("Path", path); + var result = ps.Invoke(); + if (ps.HadErrors) + { + var err = ps.Streams.Error.FirstOrDefault(); + if (err != null) throw err.Exception ?? new InvalidOperationException(err.ToString()); + } + return ToHashtable(result[0].BaseObject) ?? throw new InvalidOperationException("Unexpected null manifest"); + } + catch (Exception ex) when (IsDynamicExpressionsError(ex)) + { + cmdlet?.WriteDebug($"{path} is a Manifest with dynamic expressions. Attempting to safe evaluate..."); + var scriptBlock = ScriptBlock.Create(File.ReadAllText(path)); + scriptBlock.CheckRestrictedLanguage([], ["PSEdition", "PSScriptRoot"], true); + var rawResult = scriptBlock.InvokeReturnAsIs(); + return ToHashtable(rawResult) ?? throw new InvalidOperationException("Dynamic manifest evaluation returned null"); + } + } + + private static bool IsDynamicExpressionsError(Exception ex) + { + const string marker = "dynamic expressions"; + for (var e = ex; e != null; e = e.InnerException) + if (e.Message.Contains(marker, StringComparison.OrdinalIgnoreCase)) + return true; + return false; + } + + private static Hashtable? ToHashtable(object? obj) + { + if (obj == null) return null; + if (obj is Hashtable ht) return ht; + if (obj is PSObject pso) return ToHashtable(pso.BaseObject); + if (obj is System.Collections.IDictionary dict) + { + var result = new Hashtable(StringComparer.OrdinalIgnoreCase); + foreach (System.Collections.DictionaryEntry kv in dict) + result[kv.Key] = kv.Value; + return result; + } + return null; + } + + /// + /// Converts a manifest file path to a ModuleFastInfo object. + /// + public static ModuleFastInfo ConvertFromModuleManifest(string manifestPath, PSCmdlet? cmdlet = null) + { + var manifestName = Path.GetFileNameWithoutExtension(manifestPath); + var manifestData = ImportModuleManifest(manifestPath, cmdlet); + + if (!Version.TryParse(manifestData["ModuleVersion"]?.ToString() ?? "", out var manifestVersionData)) + throw new InvalidDataException($"The manifest at {manifestPath} has an invalid ModuleVersion. This is probably an invalid or corrupt manifest"); + + var prerelease = (manifestData["PrivateData"] as Hashtable)?["PSData"] is Hashtable psData + ? psData["Prerelease"]?.ToString() + : null; + + var manifestVersion = new NuGetVersion(manifestVersionData, prerelease); + var info = new ModuleFastInfo(manifestName, manifestVersion, new Uri(manifestPath)); + + if (manifestData["GUID"] is string guidStr && Guid.TryParse(guidStr, out var guid)) + info.Guid = guid; + + return info; + } + + /// + /// Fast scan of a .psd1 file to read only the ModuleVersion line without full parse. + /// + public static Version? TryReadModuleVersionFast(string manifestPath) + { + if (!File.Exists(manifestPath)) return null; + using var reader = new StreamReader(manifestPath); + string? line; + while ((line = reader.ReadLine()) != null) + { + var m = System.Text.RegularExpressions.Regex.Match(line, + @"\s*ModuleVersion\s*=\s*['""](?.+?)['""]"); + if (m.Success && Version.TryParse(m.Groups["version"].Value, out var v)) + return v; + } + return null; + } +} \ No newline at end of file diff --git a/Source/ModuleFast/NuGetModels.cs b/Source/ModuleFast/NuGetModels.cs new file mode 100644 index 0000000..7812a70 --- /dev/null +++ b/Source/ModuleFast/NuGetModels.cs @@ -0,0 +1,54 @@ +using System.Text.Json.Serialization; + +namespace ModuleFast; + +public class RegistrationIndex +{ + public RegistrationResource[] Resources { get; set; } = []; +} + +public class RegistrationResource +{ + [JsonPropertyName("@type")] public string Type { get; set; } = ""; + [JsonPropertyName("@id")] public string Id { get; set; } = ""; +} + +public class RegistrationResponse +{ + public int Count { get; set; } + public RegistrationPage[] Items { get; set; } = []; +} + +public class RegistrationPage +{ + [JsonPropertyName("@id")] public string Id { get; set; } = ""; + public string? Lower { get; set; } + public string? Upper { get; set; } + public RegistrationLeaf[]? Items { get; set; } +} + +public class RegistrationLeaf +{ + public string? PackageContent { get; set; } + public CatalogEntry CatalogEntry { get; set; } = new(); +} + +public class CatalogEntry +{ + [JsonPropertyName("id")] public string Id { get; set; } = ""; + public string Version { get; set; } = ""; + public string[]? Tags { get; set; } + public DependencyGroup[]? DependencyGroups { get; set; } + public string? PackageContent { get; set; } +} + +public class DependencyGroup +{ + public Dependency[]? Dependencies { get; set; } +} + +public class Dependency +{ + [JsonPropertyName("id")] public string Id { get; set; } = ""; + public string? Range { get; set; } +} \ No newline at end of file diff --git a/Source/ModuleFast/PathHelper.cs b/Source/ModuleFast/PathHelper.cs new file mode 100644 index 0000000..fda29e7 --- /dev/null +++ b/Source/ModuleFast/PathHelper.cs @@ -0,0 +1,124 @@ +using System.Management.Automation; +using System.Reflection; + +namespace ModuleFast; + +public enum InstallScope { CurrentUser, AllUsers } + +public static class PathHelper +{ + public static string? GetPSDefaultModulePath(bool allUsers) + { + try + { + var scopeType = Type.GetType("System.Management.Automation.Configuration.ConfigScope, System.Management.Automation") + ?? typeof(PSCmdlet).Assembly.GetType("System.Management.Automation.Configuration.ConfigScope"); + if (scopeType == null) return null; + + var pscType = scopeType.Assembly.GetType("System.Management.Automation.Configuration.PowerShellConfig"); + if (pscType == null) return null; + + var instance = pscType.GetField("Instance", BindingFlags.Static | BindingFlags.NonPublic)?.GetValue(null); + if (instance == null) return null; + + var method = pscType.GetMethod("GetModulePath", BindingFlags.Instance | BindingFlags.NonPublic); + if (method == null) return null; + + var scopeValue = allUsers + ? Enum.Parse(scopeType, "AllUsers") + : Enum.Parse(scopeType, "CurrentUser"); + + return method.Invoke(instance, [scopeValue]) as string; + } + catch + { + return null; + } + } + + public static void AddDestinationToPSModulePath(string destination, bool noProfileUpdate, PSCmdlet cmdlet) + { + destination = Path.GetFullPath(destination); + + var modulePaths = (Environment.GetEnvironmentVariable("PSModulePath") ?? "") + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); + + if (modulePaths.Contains(destination, StringComparer.OrdinalIgnoreCase)) + { + cmdlet.WriteDebug($"Destination '{destination}' is already in PSModulePath."); + return; + } + + cmdlet.WriteVerbose($"Updating PSModulePath to include {destination}"); + Environment.SetEnvironmentVariable("PSModulePath", + destination + Path.PathSeparator + Environment.GetEnvironmentVariable("PSModulePath")); + + if (noProfileUpdate) + { + cmdlet.WriteDebug("Skipping profile update because -NoProfileUpdate was specified."); + return; + } + + var myProfile = (string?)cmdlet.GetVariableValue("profile.CurrentUserAllHosts") + ?? (string?)cmdlet.GetVariableValue("profile"); + if (string.IsNullOrEmpty(myProfile)) return; + + if (!File.Exists(myProfile)) + { + if (!ApproveAction(myProfile, $"Allow ModuleFast to work by creating a profile at {myProfile}.", cmdlet)) + return; + cmdlet.WriteVerbose("User All Hosts profile not found, creating one."); + Directory.CreateDirectory(Path.GetDirectoryName(myProfile) ?? "."); + File.WriteAllText(myProfile, ""); + } + + // Use relative destination if possible + var displayDestination = destination; + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + foreach (var basePath in new[] { localAppData, home }) + { + var rel = Path.GetRelativePath(basePath, destination); + if (rel != destination) + { + displayDestination = "$([environment]::GetFolderPath('LocalApplicationData'))" + + Path.DirectorySeparatorChar + rel; + break; + } + } + + var profileLine = $"if (\"{displayDestination}\" -notin ($env:PSModulePath.split([IO.Path]::PathSeparator))) {{ $env:PSModulePath = \"{displayDestination}\" + $([IO.Path]::PathSeparator + $env:PSModulePath) }} #Added by ModuleFast."; + + var profileContent = File.ReadAllText(myProfile); + if (!profileContent.Contains(profileLine)) + { + if (!ApproveAction(myProfile, $"Allow ModuleFast to add {destination} to PSModulePath on startup.", cmdlet)) + return; + cmdlet.WriteVerbose($"Adding {destination} to profile {myProfile}"); + File.AppendAllText(myProfile, "\n\n" + profileLine + "\n"); + } + else + { + cmdlet.WriteVerbose($"PSModulePath {destination} already in profile, skipping..."); + } + } + + public static bool ApproveAction(string target, string action, PSCmdlet cmdlet) + { + var message = $"Performing the operation \"{action}\" on target \"{target}\""; + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"))) + { + cmdlet.WriteVerbose($"{message} (Auto-Confirmed because $ENV:CI is specified)"); + return true; + } + + var confirmPrefObj = cmdlet.GetVariableValue("ConfirmPreference"); + if (confirmPrefObj?.ToString() == "None") + { + cmdlet.WriteVerbose($"{message} (Auto-Confirmed because ConfirmPreference is None)"); + return true; + } + + return cmdlet.ShouldProcess(target, action); + } +} \ No newline at end of file diff --git a/Source/ModuleFast/SpecFileReader.cs b/Source/ModuleFast/SpecFileReader.cs new file mode 100644 index 0000000..15fc010 --- /dev/null +++ b/Source/ModuleFast/SpecFileReader.cs @@ -0,0 +1,383 @@ +using System.Collections; +using System.Management.Automation; +using System.Management.Automation.Language; +using System.Text.Json; +using System.Text.RegularExpressions; + +using Microsoft.PowerShell.Commands; + +using NuGet.Versioning; + +namespace ModuleFast; + +public enum SpecFileType { AutoDetect, ModuleFast, PSResourceGet, PSDepend } + +public static class SpecFileReader +{ + private static readonly JsonSerializerOptions _jsonOpts = new() { PropertyNameCaseInsensitive = true }; + private static readonly Regex _psDependExtendedKeyRegex = new(@"^(.+)::(.+)$", RegexOptions.Compiled); + + public static IEnumerable FindRequiredSpecFiles(string path) + { + var resolvedPath = Path.GetFullPath(path); + var requireFiles = Directory.GetFiles(resolvedPath, "*.requires.*") + .Where(f => + { + var ext = Path.GetExtension(f); + return ext is ".psd1" or ".ps1" or ".psm1" or ".json" or ".jsonc"; + }) + .ToArray(); + + if (requireFiles.Length == 0) + throw new NotSupportedException($"Could not find any required spec files in {path}. Verify the path is correct or provide Module Specifications."); + + return requireFiles; + } + + public static SpecFileType SelectRequiredSpecFileType(IDictionary spec) + { + foreach (string key in spec.Keys.Cast().Select(k => k?.ToString() ?? "")) + { + if (key.Contains("::") || key.Contains("/")) + return SpecFileType.PSDepend; + if (key == "PSDependOptions") + return SpecFileType.PSDepend; + + if (spec[key] is IDictionary valueDict) + { + if (valueDict.Contains("DependencyType")) + return SpecFileType.PSDepend; + if (valueDict.Contains("Repository") || valueDict.Contains("Version")) + return SpecFileType.PSResourceGet; + } + } + return SpecFileType.ModuleFast; + } + + public static ModuleFastSpec[] ConvertFromRequiredSpec( + string requiredSpecPath, + SpecFileType fileType = SpecFileType.AutoDetect, + PSCmdlet? cmdlet = null) + { + var spec = ReadRequiredSpecFile(requiredSpecPath, cmdlet); + return ConvertFromObject(spec, fileType, cmdlet); + } + + private static ModuleFastSpec[] ConvertFromObject(object? requiredSpec, SpecFileType fileType, PSCmdlet? cmdlet) + { + if (requiredSpec == null) + throw new InvalidDataException("Could not evaluate the Required Specification to a known format."); + + // Pass-through types + if (requiredSpec is ModuleFastSpec[] mfSpecArray) return mfSpecArray; + if (requiredSpec is ModuleFastSpec mfSpec) return [mfSpec]; + if (requiredSpec is string[] strArray) return strArray.Select(s => new ModuleFastSpec(s)).ToArray(); + if (requiredSpec is string str) return [new ModuleFastSpec(str)]; + if (requiredSpec is ModuleSpecification[] msArray) return msArray.Select(ms => new ModuleFastSpec(ms)).ToArray(); + if (requiredSpec is ModuleSpecification ms2) return [new ModuleFastSpec(ms2)]; + + // Convert PSCustomObject/dynamic JSON object to dictionary + if (requiredSpec is System.Management.Automation.PSObject pso && pso.BaseObject is not IDictionary) + { + var ht = new Hashtable(StringComparer.OrdinalIgnoreCase); + foreach (var prop in pso.Properties) + ht[prop.Name] = prop.Value; + requiredSpec = ht; + } + + if (requiredSpec is IDictionary dict) + { + if (fileType == SpecFileType.AutoDetect) + fileType = SelectRequiredSpecFileType(dict); + + return fileType switch + { + SpecFileType.PSDepend => ConvertFromPSDepend(dict, cmdlet), + SpecFileType.PSResourceGet => ConvertFromPSResourceGet(dict, cmdlet), + _ => ConvertFromModuleFastDict(dict, cmdlet) + }; + } + + // Handle arrays + if (requiredSpec is object[] objArr) + { + if (objArr.All(o => o is string)) + return objArr.Cast().Select(s => new ModuleFastSpec(s)).ToArray(); + } + + throw new InvalidDataException("Could not evaluate the Required Specification to a known format."); + } + + private static ModuleFastSpec[] ConvertFromModuleFastDict(IDictionary dict, PSCmdlet? cmdlet) + { + var results = new List(); + foreach (DictionaryEntry kv in dict) + { + var key = kv.Key?.ToString() ?? throw new InvalidDataException("Keys must be strings"); + var value = kv.Value?.ToString() ?? ""; + + if (kv.Value is IDictionary) + throw new NotSupportedException("ModuleFast SpecFile detected but the value is a hashtable. Try using -SpecFileType parameter if you expected another format."); + + if (kv.Value is not string) + throw new NotSupportedException("Only strings and hashtables are supported on the right hand side."); + + if (value == "latest") + { + results.Add(new ModuleFastSpec(key)); + continue; + } + if (NuGetVersion.TryParse(value, out _)) + { + results.Add(new ModuleFastSpec(key, value)); + continue; + } + if (VersionRange.TryParse(value, out var vr) && vr != null) + { + results.Add(new ModuleFastSpec(key, vr)); + continue; + } + try + { + results.Add(new ModuleFastSpec($"{key}{value}")); + } + catch + { + throw new NotSupportedException($"Could not parse {value} as a valid ModuleFastSpec."); + } + } + return results.ToArray(); + } + + public static ModuleFastSpec[] ConvertFromPSDepend(IDictionary spec, PSCmdlet? cmdlet) + { + var initialSpec = new Dictionary(StringComparer.OrdinalIgnoreCase); + var specCopy = new Hashtable(StringComparer.OrdinalIgnoreCase); + foreach (DictionaryEntry kv in spec) + specCopy[kv.Key?.ToString() ?? ""] = kv.Value; + + if (specCopy.Contains("PSDependOptions")) + { + cmdlet?.WriteDebug("PSDepend Parse: PSDependOptions detected. Removing..."); + if (specCopy["PSDependOptions"] is IDictionary options) + { + if (options["DependencyType"] != null) + throw new NotSupportedException("PSDepend Parse: Top-Level DependencyType in PSDependOptions is not currently supported."); + if (options["Target"] != null) + cmdlet?.WriteWarning("PSDepend Parse: Target in PSDependOptions is not currently supported."); + } + specCopy.Remove("PSDependOptions"); + } + + foreach (DictionaryEntry kv in specCopy) + { + var key = kv.Key?.ToString() ?? ""; + if (string.IsNullOrEmpty(key)) continue; + + if (key.Contains("/")) + { + cmdlet?.WriteDebug($"PSDepend Parse: Skipping Unsupported GitHub module {key}"); + continue; + } + + var colonMatch = _psDependExtendedKeyRegex.Match(key); + if (colonMatch.Success) + { + if (colonMatch.Groups[1].Value != "PSGalleryModule") + { + cmdlet?.WriteDebug($"PSDepend Parse: Skipping {key} because its extended type is not PSGalleryModule"); + continue; + } + initialSpec[colonMatch.Groups[2].Value] = kv.Value?.ToString() ?? "latest"; + continue; + } + + if (kv.Value is string strValue) + { + initialSpec[key] = strValue; + continue; + } + + if (kv.Value is not IDictionary extValue) + throw new NotSupportedException("PSDepend Parse: Value target must be a string or hashtable"); + + if (extValue["DependencyType"]?.ToString() != "PSGalleryModule") + { + cmdlet?.WriteDebug($"PSDepend Parse: Skipping {key} because DependencyType is not PSGalleryModule"); + continue; + } + + var version = extValue["Version"]?.ToString() ?? "latest"; + var name = extValue["Name"]?.ToString() ?? key; + + if (extValue["Parameters"] is IDictionary parameters) + { + if (parameters["AllowPrerelease"] != null) + name = $"!{name}"; + } + + initialSpec[name] = version; + } + + return initialSpec + .Select(kv => kv.Value == "latest" + ? new ModuleFastSpec(kv.Key) + : new ModuleFastSpec(kv.Key, kv.Value)) + .ToArray(); + } + + public static ModuleFastSpec[] ConvertFromPSResourceGet(IDictionary spec, PSCmdlet? cmdlet) + { + var initialSpec = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (DictionaryEntry kv in spec) + { + var key = kv.Key?.ToString() ?? throw new InvalidDataException("PSResourceGet Parse: Keys must be strings."); + + if (kv.Value is string strValue) + { + initialSpec[key] = strValue; + continue; + } + + if (kv.Value is not IDictionary extValue) + throw new NotSupportedException("PSResourceGet Parse: Value target must be a string or hashtable"); + + var version = extValue["Version"]?.ToString() ?? "latest"; + + if (extValue["Prerelease"] != null) + { + cmdlet?.WriteDebug($"PSResourceGet Parse: Prerelease detected for {key}"); + key = $"!{key}"; + } + if (extValue["Repository"] != null) + cmdlet?.WriteWarning($"PSResourceGet Parse: Repository specification for {key} is not currently supported."); + + initialSpec[key] = version; + } + + var results = new List(); + foreach (var kv in initialSpec) + { + if (kv.Value == "latest") + { + results.Add(new ModuleFastSpec(kv.Key)); + continue; + } + + var value = kv.Value; + if (value.StartsWith('[') || value.StartsWith('(') || value.Contains('*')) + { + results.Add(new ModuleFastSpec(kv.Key, VersionRange.Parse(value))); + } + else + { + results.Add(new ModuleFastSpec(kv.Key, value)); + } + } + return results.ToArray(); + } + + internal static object ReadRequiredSpecFile(string requiredSpecPath, PSCmdlet? cmdlet) + { + if (Uri.TryCreate(requiredSpecPath, UriKind.Absolute, out var uri) && + uri.Scheme is "http" or "https") + { + using var client = new System.Net.Http.HttpClient(); + var content = client.GetStringAsync(requiredSpecPath).GetAwaiter().GetResult(); + if (content.AsSpan().TrimStart().StartsWith("@{".AsSpan())) + { + var tempFile = Path.GetTempFileName(); + try + { + File.WriteAllText(tempFile, content); + return ModuleManifestReader.ImportModuleManifest(tempFile, cmdlet); + } + finally { File.Delete(tempFile); } + } + return JsonSerializer.Deserialize(content, _jsonOpts)!; + } + + var resolvedPath = Path.GetFullPath(requiredSpecPath); + var extension = Path.GetExtension(resolvedPath).ToLowerInvariant(); + + if (extension == ".psd1") + { + var manifestData = ModuleManifestReader.ImportModuleManifest(resolvedPath, cmdlet); + if (manifestData.ContainsKey("ModuleVersion")) + { + var reqModules = manifestData["RequiredModules"]; + cmdlet?.WriteDebug("Detected a Module Manifest, evaluating RequiredModules"); + if (reqModules == null) + throw new InvalidDataException("The manifest does not have a RequiredModules key so ModuleFast does not know what this module requires."); + + if (reqModules is object[] arr && arr.Length == 0) + throw new InvalidDataException("The manifest does not have a RequiredModules key so ModuleFast does not know what this module requires. See Get-Help about_module_manifests for more."); + + // Convert to ModuleSpecification array + return ConvertRequiredModulesToSpecs(reqModules); + } + else + { + cmdlet?.WriteDebug("Did not detect a module manifest, passing through as-is"); + return manifestData; + } + } + + if (extension is ".ps1" or ".psm1") + { + cmdlet?.WriteDebug("PowerShell Script/Module file detected, checking for #Requires"); + var ast = System.Management.Automation.Language.Parser.ParseFile(resolvedPath, out _, out _); + var requiredModules = ast.ScriptRequirements?.RequiredModules?.ToArray(); + + if (requiredModules == null || requiredModules.Length == 0) + throw new NotSupportedException("The script does not have a #Requires -Module statement so ModuleFast does not know what this module requires. See Get-Help about_requires for more."); + + return requiredModules.Select(ms => new ModuleFastSpec(ms)).ToArray(); + } + + if (extension is ".json" or ".jsonc") + { + var content = File.ReadAllText(resolvedPath); + var json = JsonSerializer.Deserialize(content, _jsonOpts); + if (json.ValueKind == JsonValueKind.Array) + { + var strings = json.EnumerateArray().Select(e => e.GetString() ?? "").ToArray(); + return strings; + } + // Convert to dictionary + var dict = new Hashtable(StringComparer.OrdinalIgnoreCase); + foreach (var prop in json.EnumerateObject()) + dict[prop.Name] = prop.Value.ToString(); + return dict; + } + + throw new NotSupportedException("Only .ps1, .psm1, .psd1, and .json files are supported."); + } + + private static ModuleFastSpec[] ConvertRequiredModulesToSpecs(object requiredModules) + { + if (requiredModules is object[] arr) + { + var specs = new List(); + foreach (var item in arr) + { + if (item is string s) + specs.Add(new ModuleFastSpec(s)); + else if (item is ModuleSpecification ms) + specs.Add(new ModuleFastSpec(ms)); + else if (item is Hashtable ht) + specs.Add(new ModuleFastSpec(new ModuleSpecification(ht))); + else if (item is System.Management.Automation.PSObject pso) + { + if (pso.BaseObject is ModuleSpecification ms2) + specs.Add(new ModuleFastSpec(ms2)); + else if (pso.BaseObject is string str) + specs.Add(new ModuleFastSpec(str)); + } + } + return specs.ToArray(); + } + return []; + } +} \ No newline at end of file