diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..3c58094 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,30 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/dotnet +{ + "name": "ModuleFast", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/dotnet:2-10.0-noble", + "features": { + // "ghcr.io/devcontainers-extra/features/powershell:1": {} + } + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [5000, 5001], + // "portsAttributes": { + // "5001": { + // "protocol": "https" + // } + // } + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "dotnet restore", + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..661aa32 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,390 @@ +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] +indent_size = 2 + +#### 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/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..a6257e2 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,6 @@ +# Testing +- Run `Invoke-Build Test` for tests. To run an individual test, run `Invoke-Build Test -TestName `. +- To run ModuleFast directly, use `Start-Job -ScriptBlock { Import-Module .\modulefast.psm1; } | Receive-Job -Wait -AutoRemoveJob` to avoid module locking issues. + +# Style +Read `.editorconfig` for style rules and indentation. Run `dotnet format` after making changes to C# files to update the formatting. \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4c0b048 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for more information: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +# https://containers.dev/guide/dependabot + +version: 2 +updates: + - package-ecosystem: devcontainers + directory: / + schedule: + interval: weekly + - package-ecosystem: nuget + directory: / + schedule: + interval: weekly + - package-ecosystem: dotnet-sdk + directory: / + schedule: + interval: weekly \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6d44a0b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,110 @@ +name: โ™ป๏ธ ModuleFast CI + +on: + push: + branches: + - '*' + tags: + - 'v[0-9]*.[0-9]*.[0-9]*' + pull_request: + branches: [ main ] + +defaults: + run: + shell: pwsh + +jobs: + build: + name: ๐Ÿ‘ท Build + runs-on: ubuntu-24.04 + outputs: + nupkg_artifact_id: ${{ steps.upload_nupkg.outputs.artifact-id }} + steps: + - name: ๐Ÿšš Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: โšก Setup .NET + uses: actions/setup-dotnet@v5 + + - name: โšก Cache NuGet packages + uses: actions/cache@v6 + with: + path: | + ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/Directory.Packages.props') }} + restore-keys: | + ${{ runner.os }}-nuget- + - name: ๐Ÿ‘ท Build & Package + env: + MODULEVERSION: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || null }} + run: | + try { ./build.ps1 BuildNoTest } + catch { + "::error::Build failed: $($_.Exception.Message)`n$(Get-Error | Out-String)" -replace "`n","%0A" + throw + } + - name: ๐Ÿ“ค Upload Module + uses: actions/upload-artifact@v7 + with: + name: ModuleFast + path: ./Artifacts/Module + - name: ๐Ÿ“ค Upload Nupkg + id: upload_nupkg + uses: actions/upload-artifact@v7 + with: + name: ModuleFast-nupkg + path: ./Artifacts/*.nupkg + archive: false + + test: + name: ๐Ÿงช Test (${{ matrix.os }}) + needs: build + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-2025] + runs-on: ${{ matrix.os }} + steps: + - name: ๐Ÿšš Checkout + uses: actions/checkout@v7 + - name: ๐Ÿ“ฅ Download Module + uses: actions/download-artifact@v8 + with: + name: ModuleFast + path: ./Artifacts/Module + - name: ๐Ÿงช Test + run: | + $errorView = 'DetailedView' + .\build.ps1 Pester + + publish: + name: ๐Ÿšข Publish to PowerShell Gallery + if: startsWith(github.ref, 'refs/tags/v') + needs: [build, test] + environment: + name: PowerShell Gallery + url: https://www.powershellgallery.com/packages/ModuleFast + runs-on: ubuntu-24.04 + steps: + - name: ๐Ÿ“ฅ Download Nupkg + uses: actions/download-artifact@v8 + with: + artifact-ids: ${{ needs.build.outputs.nupkg_artifact_id }} + path: ./Release + - name: ๐Ÿšข Publish to PowerShell Gallery + env: + PSGALLERY_API_KEY: ${{ secrets.PSGALLERY }} + run: | + $DebugPreference = 'Continue' + $VerbosePreference = 'Continue' + + $nupkg = Get-ChildItem -Path ./Release/*.nupkg + if ($nupkg.Count -eq 0) { + throw "No nupkg found in Release directory" + } elseif ($nupkg.Count -gt 1) { + throw "Multiple nupkg files found: $($nupkg.FullName -join ', ')" + } + + Register-PSResourceRepository -PSGallery -Force + Publish-PSResource -Nupkg $nupkg.FullName -ApiKey $env:PSGALLERY_API_KEY diff --git a/.gitignore b/.gitignore index 466be66..0c24d6a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ -Build -artifacts \ No newline at end of file +/[aA]rtifacts \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..5ad08d5 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + "configurations": [ + { + "name": "PowerShell: Binary Module Interactive", + "type": "PowerShell", + "request": "launch", + "createTemporaryIntegratedConsole": true, + "attachDotnetDebugger": true + }, + { + "name": "ModuleFast: Pester", + "type": "PowerShell", + "request": "launch", + "script": "Invoke-Build Test", + "createTemporaryIntegratedConsole": true + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..6cc98b4 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,21 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "type": "shell", + "command": "Invoke-Build", + "args": [ + "Build" + ], + "group": { + "isDefault": true, + "kind": "build" + }, + "presentation": { + "reveal": "silent" + }, + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..1bc2243 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,7 @@ + + + + true + $(MSBuildThisFileDirectory)/Artifacts + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..0f38444 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,12 @@ + + + + true + + + + + + + + \ No newline at end of file diff --git a/ModuleFast.Format.ps1xml b/ModuleFast.Format.ps1xml new file mode 100644 index 0000000..d11f9fa --- /dev/null +++ b/ModuleFast.Format.ps1xml @@ -0,0 +1,70 @@ + + + + ModuleFastSpec + + ModuleFast.ModuleFastSpec + ModuleFastSpec + + + true + + + + + + + + + + + + + Name + + + $_.GetVersionString() -replace '^@' + + + + + + + + ModuleFastInfo + + ModuleFast.ModuleFastInfo + ModuleFastInfo + + + true + + + + + + + + + + + + + + + + Name + + + ModuleVersion + + + Location + + + + + + + + \ No newline at end of file diff --git a/ModuleFast.build.ps1 b/ModuleFast.build.ps1 index 48b55ce..5f08442 100644 --- a/ModuleFast.build.ps1 +++ b/ModuleFast.build.ps1 @@ -1,46 +1,61 @@ -#requires -version 7.2 +#requires -version 7.6 [CmdletBinding(ConfirmImpact = 'High')] param( #Specify this to explicitly specify the version of the package [Management.Automation.SemanticVersion]$Version = '0.0.0-SOURCE', #You Generally do not need to modify these - $Destination = (Join-Path $PSScriptRoot 'Build'), - $ModuleOutFolderPath = (Join-Path $Destination 'ModuleFast'), + $Destination = (Join-Path $PSScriptRoot 'Artifacts'), + $ModuleOutFolderPath = (Join-Path $Destination 'Module'), $TempPath = (Resolve-Path temp:).ProviderPath + '\ModuleFastBuild', - $LibPath = (Join-Path $ModuleOutFolderPath 'lib' 'netstandard2.0'), - $NugetVersioning = '6.8.0', - $NugetOutFolderPath = $Destination + # Build for release (don't include debug headers) + [switch]$Release, + $PowerShellProjectPath = (Join-Path $PSScriptRoot 'Source' 'PowerShell' 'PowerShell.csproj'), + # Filter for test names + $TestName ) $ErrorActionPreference = 'Stop' +$buildMode = $Release ? 'release' : 'debug' + # Short for common Parameters, we are using a short name here to keep the commands short $c = @{ ErrorAction = 'Stop' Verbose = $VerbosePreference -eq 'Continue' Debug = $DebugPreference -eq 'Continue' } + if ($DebugPreference -eq 'Continue') { $c.Confirm = $false } Task Clean { - foreach ($Path in $Destination, $NugetOutFolderPath, $ModuleOutFolderPath, $TempPath, $LibPath) { - if (Test-Path $Path) { - Remove-Item @c -Recurse -Force -Path $Path/* - } else { - New-Item @c -Type Directory -Path $Path | Out-Null - } - } + Write-Build -Color DarkCyan "Cleaning $Destination" + exec { git clean -fdX $Destination } +} + +Task BuildCSharp { + # Build the PowerShell module project (which depends on Core) + exec { dotnet build $PowerShellProjectPath --nologo -c $buildMode } +} + +Task Publish { + exec { & dotnet publish $PowerShellProjectPath -c $buildMode } } Task CopyFiles { + New-Item -ItemType Directory -Path $ModuleOutFolderPath -Force | Out-Null Copy-Item @c -Path @( 'ModuleFast.psd1' 'ModuleFast.psm1' + 'ModuleFast.Format.ps1xml' '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 $Destination 'publish' 'PowerShell' $buildMode + Copy-Item @c -Path (Join-Path $artifactsBinPath '*') -Destination $ModuleOutFolderPath -Recurse -Force } Task Version { @@ -53,24 +68,8 @@ Task Version { $manifestContent | Set-Content -Path $manifestPath } -Task GetNugetVersioningAssembly { - PackageManagement\Install-Package @c -Name Nuget.Versioning -RequiredVersion $NuGetVersioning -Destination $tempPath -Force | Out-Null - Copy-Item @c -Path "$tempPath/NuGet.Versioning.$NuGetVersioning/lib/netstandard2.0/NuGet.Versioning.dll" -Destination $libPath -Recurse -Force -} - -Task AddNugetVersioningAssemblyRequired { - (Get-Content -Raw -Path $ModuleOutFolderPath\ModuleFast.psd1) -replace [Regex]::Escape('# RequiredAssemblies = @()'), 'RequiredAssemblies = @(".\lib\netstandard2.0\NuGet.Versioning.dll")' | Set-Content -Path $ModuleOutFolderPath\ModuleFast.psd1 -} - Task Package.Nuget { - [string]$repoName = 'ModuleFastBuild-' + (New-Guid) - Get-ChildItem $ModuleOutFolderPath -Recurse -Include '*.nupkg' | Remove-Item @c -Force - try { - Register-PSResourceRepository -Name $repoName -Uri $NugetOutFolderPath -ApiVersion local - Publish-PSResource -Repository $repoName -Path $ModuleOutFolderPath - } finally { - Unregister-PSResourceRepository -Name $repoName - } + Compress-PSResource @c -Path $ModuleOutFolderPath -DestinationPath $Destination } Task Package.Zip { @@ -83,22 +82,30 @@ Task Package.Zip { Task Pester { #Run this in a separate job so as not to lock any NuGet DLL packages for future runs. Runspace would lock the package to this process still. - Start-Job { - Invoke-Pester + $result = Start-Job { + $TestFilter = $using:TestName + if ($TestFilter) { + Write-Host -ForegroundColor DarkCyan "Only Running Tests Containing: $TestFilter" + $TestFilter = '*' + $TestFilter + '*' + } + + + $ProgressPreference = 'SilentlyContinue' + Invoke-Pester -PassThru -FullNameFilter $TestFilter } | Receive-Job -Wait -AutoRemoveJob + + assert ($result.FailedCount -eq 0) "$($result.FailedCount) Pester tests failed." } Task Package Package.Nuget, Package.Zip #Supported High Level Tasks Task Build @( - 'Clean' + 'Publish' 'CopyFiles' 'Version' - 'GetNugetVersioningAssembly' - 'AddNugetVersioningAssemblyRequired' ) Task Test Build, Pester Task . Build, Test, Package -Task BuildNoTest Build, Package +Task BuildNoTest Build, Package \ No newline at end of file diff --git a/ModuleFast.ps1 b/ModuleFast.ps1 index f1cdb52..aaeb781 100644 --- a/ModuleFast.ps1 +++ b/ModuleFast.ps1 @@ -4,7 +4,6 @@ using namespace System.IO.Compression using namespace System.Reflection #requires -version 7.2 - # This is the bootstrap script for Modules #We cannot use CmdletBinding here because we need the special $args variable for passthrough to Install-ModuleFast @@ -15,18 +14,12 @@ param ( [string]$User = 'JustinGrote', #Specify the repo [string]$Repo = 'ModuleFast', - #Specify the module file - [string]$ModuleFile = 'ModuleFast.psm1', #Entrypoint to be used if additional args are specified [string]$EntryPoint = 'Install-ModuleFast', #Specify the module name [string]$ModuleName = 'ModuleFast', #Path of the module to bootstrap. You normally won't change this but you can override it if you want - [string]$Uri = $( - $base = "https://github.com/$User/$Repo/releases/{0}/$ModuleFile" - $version = if ($Release -eq 'latest') { 'latest/download' } else { "download/$Release" } - $base -f $version - ), + [string]$Uri, #Used for testing [switch]$ImportNugetVersioning, #Used for testing @@ -36,16 +29,16 @@ param ( [switch]$Confirm ) -if ($verbose) { $VerbosePreference = 'Continue' } -if ($debug) { $DebugPreference = 'Continue' } - -$ErrorActionPreference = 'Stop' - #Bootstrap via IEX does not evaluate requires so we need an additional check here: if ($PSVersionTable.PSVersion -lt '7.2.0') { throw [NotSupportedException]'The ModuleFast Bootstrap script requires PowerShell 7.2 or higher. Specific ModuleFast versions may have more strict requirements.' } +if ($verbose) { $VerbosePreference = 'Continue' } +if ($debug) { $DebugPreference = 'Continue' } + +$ErrorActionPreference = 'Stop' + #We need to load the versioning assembly before we load the dependent classes #We inline this in order to keep ModuleFast self-contained function Import-NuGetVersioningAssembly { @@ -70,37 +63,79 @@ vL0HfFzFtQc8t+zdorZF2lUvbqy1K2FbuFPcjcG9U4wsS2t7saQVu5KxMTZSTO+OCcWBgMEkECCEGgiQ } } } -Import-NuGetVersioningAssembly -if ($ImportNugetVersioning) { return } -if (-not (Get-Module $ModuleName)) { - #Dont use a release, use the latest commit on main - if ($UseMain -or $Release -eq 'main') { - $Uri = "https://raw.githubusercontent.com/$User/$Repo/main/$ModuleName.psm1" - } +function Get-ReleaseBaseUri { + $version = if ($Release -eq 'latest') { 'latest/download' } else { "download/$Release" } + $base = "https://github.com/$User/$Repo/releases/{0}/" + $base -f $version +} + +function Import-LegacyModuleFast { + param( + [string]$LegacyRelease = '0.6.1' + ) - Write-Debug "Fetching $ModuleName from $Uri" - $ProgressPreference = 'SilentlyContinue' + Import-NuGetVersioningAssembly + if ($ImportNugetVersioning) { return $null } + + $legacyUri = "https://github.com/$User/$Repo/releases/download/v$LegacyRelease/ModuleFast.psm1" + Write-Debug "Fetching legacy $ModuleName from $legacyUri" try { - $response = [HttpClient]::new().GetStringAsync($Uri).GetAwaiter().GetResult() + $response = [HttpClient]::new().GetStringAsync($legacyUri).GetAwaiter().GetResult() } catch { - $PSItem.ErrorDetails = "Failed to fetch $ModuleName from $Uri`: $PSItem" - $PSCmdlet.ThrowTerminatingError($PSItem) + throw "Failed to fetch $ModuleName from $legacyUri`: $PSItem" } - Write-Debug 'Fetched response' - $scriptBlock = [ScriptBlock]::Create($response) - $ProgressPreference = 'Continue' - $bootstrapModule = New-Module -Name $ModuleName -ScriptBlock $scriptblock - Write-Debug "Loaded Module $ModuleName" -} else { - Write-Warning "Module $ModuleName already loaded, skipping bootstrap." + Write-Debug 'Fetched legacy module response' + $scriptBlock = [ScriptBlock]::Create($response) + $script:bootstrapModule = New-Module -Name $ModuleName -ScriptBlock $scriptBlock + Write-Debug "Loaded legacy script module $ModuleName $LegacyRelease" + return $script:bootstrapModule } -#This is ModuleFast specific -if ($UseMain) { - Write-Debug 'UseMain Specified, ModuleFast will use preview.pwsh.gallery' - & $bootstrapModule { $SCRIPT:DefaultSource = 'https://preview.pwsh.gallery/index.json' } +if (-not (Get-Module $ModuleName)) { + $useBinaryModule = $PSVersionTable.PSVersion -gt '7.6.0' + + if ($useBinaryModule -and -not ($UseMain -or $Release -eq 'main')) { + Write-Debug 'PowerShell > 7.6 detected, trying binary module bootstrap from latest release' + try { + $releaseCacheKey = if ($Release -eq 'latest') { 'latest' } else { $Release } + $extractPath = [Path]::Combine([Path]::GetTempPath(), 'ModuleFast-Bootstrap', $releaseCacheKey) + $manifestPath = Join-Path $extractPath "$ModuleName.psd1" + + if (-not (Test-Path $manifestPath)) { + [Directory]::CreateDirectory($extractPath) | Out-Null + $nupkgPath = Join-Path $extractPath 'ModuleFast.nupkg' + $downloadUrl = (Get-ReleaseBaseUri) + 'ModuleFast.nupkg' + + Write-Debug "Downloading nupkg from $downloadUrl to $nupkgPath" + $ProgressPreference = 'SilentlyContinue' + Invoke-RestMethod -Uri $downloadUrl -OutFile $nupkgPath -Debug:$false -ErrorAction Stop + Write-Debug "Extracting nupkg from $nupkgPath to $extractPath" + [ZipFile]::ExtractToDirectory($nupkgPath, $extractPath, $true) + } else { + Write-Debug "Bootstrap cache exists at $extractPath" + } + + if (-not (Test-Path $manifestPath)) { + throw "Module manifest not found at $manifestPath after extraction" + } + + Import-Module $manifestPath -Global + Write-Debug "Loaded binary module $ModuleName from $extractPath" + } catch { + if (-not $PSItem.Exception.StatusCode) { + Write-Verbose "Binary bootstrap failed with unknown error: $($PSItem.Exception.Message). Falling back to legacy 0.6.1 bootstrap." + } elseif ($PSItem.Exception.StatusCode -eq 'NotFound') { + Write-Verbose "Module $Release nupkg not found at $downloadUrl. This is probably a bug. Falling back to legacy 0.6.1 bootstrap." + } else { + Write-Verbose "Binary bootstrap failed with status code $($PSItem.Exception.StatusCode). Falling back to legacy 0.6.1 bootstrap." + } + Import-LegacyModuleFast | Out-Null + } finally { + $ProgressPreference = 'Continue' + } + } } if ($args) { diff --git a/ModuleFast.psd1 b/ModuleFast.psd1 index 054a53b..3f3c878 100644 --- a/ModuleFast.psd1 +++ b/ModuleFast.psd1 @@ -33,7 +33,7 @@ Description = 'Optimizes the PowerShell Module Installation Process to be as fast as possible and operate in CI/CD scenarios in a declarative manner' # Minimum version of the PowerShell engine required by this module - PowerShellVersion = '7.2' #Due to use of CLEAN block + PowerShellVersion = '7.6' #Due to use of new ZIP apis # Name of the PowerShell host required by this module # PowerShellHostName = '' @@ -63,16 +63,16 @@ # TypesToProcess = @() # Format files (.ps1xml) to be loaded when importing this module - # FormatsToProcess = @() + FormatsToProcess = @('ModuleFast.Format.ps1xml') # Modules to import as nested modules of the module specified in RootModule/ModuleToProcess # 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', 'Clear-ModuleFastCache' # Variables to export from this module #VariablesToExport = '*' diff --git a/ModuleFast.psm1 b/ModuleFast.psm1 index e3fb73a..0f1df27 100644 --- a/ModuleFast.psm1 +++ b/ModuleFast.psm1 @@ -1,2332 +1,60 @@ -#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 +#requires -version 7.6 -#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' - -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 -} - -#Default Source is PWSH Gallery -$SCRIPT:DefaultSource = 'https://pwsh.gallery/index.json' - -#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 - } - } else { - #Non-standard destination specified. Don't update profile - $NoProfileUpdate = $true - } - - 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" - } +# Load the C# binary module โ€” probe Artifacts Output Layout paths first, then +# the classic deployed path (bin/ModuleFast/ModuleFast.dll). +$binaryModulePaths = @( + if ($env:MODULEFASTDEBUG) { + Write-Host -Fore Green "ModuleFast Debug Mode Enabled: Looking for binary module in Artifacts Output Layout" + $buildTypes = 'debug', 'release' + $artifactsDir = Join-Path $PSScriptRoot 'Artifacts' + $buildDir = Join-Path $artifactsDir 'bin' 'PowerShell' + $publishDir = Join-Path $artifactsDir 'publish' 'PowerShell' + foreach ($buildType in $buildTypes) { + # Build artifacts first + (Join-Path $buildDir $buildType 'ModuleFast.dll') + # Publish Artifacts Next + (Join-Path $publishDir $buildType 'ModuleFast.dll') } - 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 = @{} + # Classic deployed layout in same folder + (Join-Path $PSScriptRoot 'ModuleFast.dll') + # Published Output as a debug fallback + (Join-Path $PSScriptRoot 'Artifacts', 'Module', 'ModuleFast.dll') +) - #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() - } - } -} - -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) - - #Perform a case insensitive search for the manifest file - $manifestPath = Get-ChildItem -Path $installPath -File -Filter '*.psd1' -ErrorAction Ignore | - Where-Object { $_.BaseName -ieq $context.Module.Name } | - Select-Object -First 1 -ExpandProperty FullName - - if (-not $manifestPath) { - throw [IO.FileNotFoundException]"$($context.Module): Could not find manifest file matching '$($context.Module.Name).psd1' in $installPath" - } - - # 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} - } - } - } - } -} - -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) +$binaryModulePath = foreach ($path in $binaryModulePaths) { + Write-Debug "Looking for binary module at path: $path" + if (Test-Path $path) { + Write-Debug "โœ… Found binary module at path: $path" + $path 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 +if (-not $binaryModulePath) { + throw "Binary module DLL not found in expected paths: $($binaryModulePaths -join ', '). This is probably a bug." } -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' - )] +Write-Debug "Importing binary module from path: $binaryModulePath" +Import-Module $binaryModulePath -Force - <# - .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 $myProfile) { - Write-Verbose "CurrentUserAllHosts profile path is $myProfile" - } elseif ($host.Name -eq 'Visual Studio Code Host') { - Write-Verbose "Visual Studio Code Host detected." - $myProfileBase = $isWindows ? - [Environment]::GetFolderPath('MyDocuments') : - [Environment]::GetFolderPath('ApplicationData') - $myProfile = Join-Path $myProfileBase 'powershell' 'profile.ps1' - } - - 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 +#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)) { + [void]$accelerators::Add($pair.Key, $pair.Value) } - } - 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 +$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = { + $accelerators = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators') + 'ModuleFastSpec', 'ModuleFastInfo', 'SpecFileType', 'InstallScope' | ForEach-Object { + [void]$accelerators::Remove($_) } - - #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..5d0f3c9 --- /dev/null +++ b/ModuleFast.slnx @@ -0,0 +1,7 @@ + + + + + + + diff --git a/ModuleFast.tests.ps1 b/ModuleFast.tests.ps1 index 75e7ccc..045c30f 100644 --- a/ModuleFast.tests.ps1 +++ b/ModuleFast.tests.ps1 @@ -3,9 +3,10 @@ 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 +$env:MODULEFASTDEBUG = $true +Import-Module $PSScriptRoot/ModuleFast.psd1 -Force BeforeAll { if ($env:MFURI) { @@ -13,78 +14,67 @@ 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 } } } -Describe 'Get-ModuleFastPlan' -Tag 'E2E' { +Describe 'Install-ModuleFast -Plan' -Tag 'E2E' { BeforeAll { $SCRIPT:__existingPSModulePath = $env:PSModulePath $env:PSModulePath = $testDrive @@ -143,7 +133,7 @@ Describe 'Get-ModuleFastPlan' -Tag 'E2E' { ) It 'Gets Module with Parameter: ' { - $actual = Get-ModuleFastPlan $Spec + $actual = Install-ModuleFast $Spec -Plan $actual | Should -HaveCount 1 $ModuleName | Should -Be $actual.Name $actual.ModuleVersion | Should -Not -BeNullOrEmpty @@ -151,7 +141,7 @@ Describe 'Get-ModuleFastPlan' -Tag 'E2E' { } -TestCases $moduleSpecTestCases It 'Gets Module with Pipeline: ' { - $actual = $Spec | Get-ModuleFastPlan + $actual = $Spec | Install-ModuleFast -Plan $actual | Should -HaveCount 1 $ModuleName | Should -Be $actual.Name $actual.ModuleVersion | Should -Not -BeNullOrEmpty @@ -161,13 +151,13 @@ Describe 'Get-ModuleFastPlan' -Tag 'E2E' { Context 'StrictSemVer Parameter' { It 'StrictSemVer matches prereleases with exclusive upper bound' { - $actual = Get-ModuleFastPlan 'PrereleaseTest!<0.0.2' -StrictSemVer + $actual = Install-ModuleFast 'PrereleaseTest!<0.0.2' -StrictSemVer -Plan $actual | Should -HaveCount 1 $actual.ModuleVersion.IsPrerelease | Should -Be $true $actual.ModuleVersion.Patch | Should -Be 2 } It 'StrictSemVer not specified does not match prereleases with exclusive upper bound' { - $actual = Get-ModuleFastPlan 'PrereleaseTest!<0.0.2' + $actual = Install-ModuleFast 'PrereleaseTest!<0.0.2' -Plan $actual | Should -HaveCount 1 $actual.ModuleVersion.IsPrerelease | Should -Be $false $actual.ModuleVersion.Patch | Should -Be 1 @@ -355,13 +345,13 @@ Describe 'Get-ModuleFastPlan' -Tag 'E2E' { ) It 'Fails if hashtable-style string parameter is not a modulespec' { - { Get-ModuleFastPlan '@{ModuleName = ''Az.Accounts''; ModuleVersion = ''2.7.3''; InvalidParameter = ''ThisShouldNotBeValid''}' -ErrorAction Stop } - | Should -Throw '*Cannot process argument transformation on parameter*' + { Install-ModuleFast '@{ModuleName = ''Az.Accounts''; ModuleVersion = ''2.7.3''; InvalidParameter = ''ThisShouldNotBeValid''}' -Plan -ErrorAction Stop } + | Should -Throw '*not valid ModuleSpecification syntax*' } It 'Gets Module with String Parameter: ' { try { - $actual = Get-ModuleFastPlan $Spec + $actual = Install-ModuleFast $Spec -Plan $actual | Should -HaveCount 1 $ModuleName | Should -Be $actual.Name $actual.ModuleVersion | Should -Not -BeNullOrEmpty @@ -374,7 +364,7 @@ Describe 'Get-ModuleFastPlan' -Tag 'E2E' { It 'Gets Module with String Pipeline: ' { try { - $actual = $Spec | Get-ModuleFastPlan + $actual = $Spec | Install-ModuleFast -Plan $actual | Should -HaveCount 1 $ModuleName | Should -Be $actual.Name $actual.ModuleVersion | Should -Not -BeNullOrEmpty @@ -387,7 +377,7 @@ Describe 'Get-ModuleFastPlan' -Tag 'E2E' { Context 'ModuleFastSpec Combinations' { It 'Strings as Parameter' { - $actual = Get-ModuleFastPlan 'Az.Accounts', 'Az.Compute', 'ImportExcel' + $actual = Install-ModuleFast 'Az.Accounts', 'Az.Compute', 'ImportExcel' -Plan $actual | Should -HaveCount 3 $actual | ForEach-Object { $PSItem.Name | Should -BeIn 'Az.Accounts', 'Az.Compute', 'ImportExcel' @@ -395,15 +385,20 @@ Describe 'Get-ModuleFastPlan' -Tag 'E2E' { } } It 'Strings as Pipeline' { - $actual = 'Az.Accounts', 'Az.Compute', 'ImportExcel' | Get-ModuleFastPlan + $actual = 'Az.Accounts', 'Az.Compute', 'ImportExcel' | Install-ModuleFast -Plan $actual | Should -HaveCount 3 $actual | ForEach-Object { $PSItem.Name | Should -BeIn 'Az.Accounts', 'Az.Compute', 'ImportExcel' $PSItem.ModuleVersion | Should -BeGreaterThan '1.0' } } + It 'Plan output is sorted alphabetically by module name' { + $actual = Install-ModuleFast 'Az.Compute', 'Az.Accounts' -Plan + $actual | Should -HaveCount 2 + ($actual | Select-Object -ExpandProperty Name) | Should -Be @('Az.Accounts', 'Az.Compute') + } It 'ModuleSpecs as Parameter' { - $actual = Get-ModuleFastPlan 'Az.Accounts', '@{ModuleName = "Az.Compute"; ModuleVersion = "1.0.0" }', ([ModuleSpecification]::new('ImportExcel')) + $actual = Install-ModuleFast 'Az.Accounts', '@{ModuleName = "Az.Compute"; ModuleVersion = "1.0.0" }', ([ModuleSpecification]::new('ImportExcel')) -Plan $actual | Should -HaveCount 3 $actual | ForEach-Object { $PSItem.Name | Should -BeIn 'Az.Accounts', 'Az.Compute', 'ImportExcel' @@ -411,7 +406,7 @@ Describe 'Get-ModuleFastPlan' -Tag 'E2E' { } } It 'ModuleSpecs as Pipeline' { - $actual = 'Az.Accounts>1', '@{ModuleName = "Az.Compute"; ModuleVersion = "1.0.0" }', ([ModuleSpecification]::new('ImportExcel')) | Get-ModuleFastPlan + $actual = 'Az.Accounts>1', '@{ModuleName = "Az.Compute"; ModuleVersion = "1.0.0" }', ([ModuleSpecification]::new('ImportExcel')) | Install-ModuleFast -Plan $actual | Should -HaveCount 3 $actual | ForEach-Object { $PSItem.Name | Should -BeIn 'Az.Accounts', 'Az.Compute', 'ImportExcel' @@ -421,7 +416,7 @@ Describe 'Get-ModuleFastPlan' -Tag 'E2E' { It 'Prerelease does not affect non-prerelease' { #The prerelease flag on az.accounts should not trigger prerelease on PrereleaseTest - $actual = 'Az.Accounts!', 'PrereleaseTest' | Get-ModuleFastPlan + $actual = 'Az.Accounts!', 'PrereleaseTest' | Install-ModuleFast -Plan $actual | Should -HaveCount 2 $actual | Where-Object Name -EQ 'PrereleaseTest' | ForEach-Object { $PSItem.ModuleVersion | Should -Be '0.0.1' @@ -429,7 +424,7 @@ Describe 'Get-ModuleFastPlan' -Tag 'E2E' { } It '-Prerelease overrides even if prerelease is not specified' { #The prerelease flag on az.accounts should not trigger prerelease on PrereleaseTest - $actual = 'Az.Accounts!', 'PrereleaseTest' | Get-ModuleFastPlan -Prerelease + $actual = 'Az.Accounts!', 'PrereleaseTest' | Install-ModuleFast -Prerelease -Plan $actual | Should -HaveCount 2 $actual | Where-Object Name -EQ 'PrereleaseTest' | ForEach-Object { $PSItem.ModuleVersion | Should -Be '0.0.2-prerelease' @@ -439,40 +434,40 @@ Describe 'Get-ModuleFastPlan' -Tag 'E2E' { } It 'Errors on Unsupported Object instead of Stringifying' { - { Get-ModuleFastPlan [Tuple]::Create('Az.Accounts') -ErrorAction Stop } - | Should -Throw '*Cannot process argument transformation on parameter*' + { Install-ModuleFast [Tuple]::Create('Az.Accounts') -Plan -ErrorAction Stop } + | Should -Throw '*Cannot bind parameter*' } It 'Gets Module with 1 dependency' { - Get-ModuleFastPlan 'Az.Compute' | Should -HaveCount 2 + Install-ModuleFast 'Az.Compute' -Plan | Should -HaveCount 2 } It 'Gets all dependencies for a Module with lots of dependencies (Az)' { - Get-ModuleFastPlan @{ModuleName = 'Az'; RequiredVersion = '11.1.0' } | Should -HaveCount 86 + Install-ModuleFast @{ModuleName = 'Az'; RequiredVersion = '11.1.0' } -Plan | Should -HaveCount 86 } It 'Gets Module with 4 section version number and a 4 section version number dependency (VMware.VimAutomation.Common)' { - Get-ModuleFastPlan 'VMware.VimAutomation.Common' | Should -HaveCount 2 + Install-ModuleFast 'VMware.VimAutomation.Common' -Plan | Should -HaveCount 2 } It 'Gets multiple modules' { - Get-ModuleFastPlan @{ModuleName = 'Az'; RequiredVersion = '11.1.0' }, @{ModuleName = 'VMWare.PowerCli'; RequiredVersion = '13.2.0.22746353' } - | Should -HaveCount 170 + Install-ModuleFast @{ModuleName = 'Az'; RequiredVersion = '11.1.0' }, @{ModuleName = 'VMWare.PowerCli'; RequiredVersion = '13.2.0.22746353' } -Plan + | Should -HaveCount 122 } It 'Casts to ModuleSpecification' { - $actual = (Get-ModuleFastPlan 'Az.Accounts') -as [Microsoft.PowerShell.Commands.ModuleSpecification] + $actual = (Install-ModuleFast 'Az.Accounts' -Plan) -as [Microsoft.PowerShell.Commands.ModuleSpecification] $actual | Should -BeOfType [Microsoft.PowerShell.Commands.ModuleSpecification] $actual.Name | Should -Be 'Az.Accounts' $actual.RequiredVersion | Should -BeGreaterThan '2.7.3' } It 'Filters Prerelease Modules by Default' { - $actual = Get-ModuleFastPlan 'PrereleaseTest' + $actual = Install-ModuleFast 'PrereleaseTest' -Plan $actual.ModuleVersion | Should -Be '0.0.1' } It 'Shows Prerelease Modules if Prerelease is specified' { - $actual = Get-ModuleFastPlan 'PrereleaseTest' -Prerelease + $actual = Install-ModuleFast 'PrereleaseTest' -Prerelease -Plan $actual.ModuleVersion | Should -Be '0.0.2-prerelease' } It 'Detects Prerelease even if Prerelease not specified' { - $actual = Get-ModuleFastPlan 'PrereleaseTest=0.0.2-prerelease' + $actual = Install-ModuleFast 'PrereleaseTest=0.0.2-prerelease' -Plan $actual.ModuleVersion | Should -Be '0.0.2-prerelease' } @@ -508,7 +503,7 @@ Describe 'Install-ModuleFast' -Tag 'E2E' { Destination = $installTempPath NoProfileUpdate = $true NoPSModulePathUpdate = $true - Confirm = $false + # Confirm = $false } } AfterAll { @@ -544,11 +539,11 @@ Describe 'Install-ModuleFast' -Tag 'E2E' { Split-Path $resolvedPath -Leaf | Should -Be '0.4.15.0' } It 'lots of dependencies (Az)' { - Install-ModuleFast @imfParams 'Az' - (Get-Module Az* -ListAvailable).count | Should -BeGreaterThan 10 + Install-ModuleFast @imfParams 'Az=11.1.0' + (Get-Module Az* -ListAvailable).count | Should -BeGreaterThan 10 } It 'specific requiredVersion' { - Install-ModuleFast @imfParams @{ ModuleName = 'Az.Accounts'; RequiredVersion = '2.7.4' } + Install-ModuleFast @imfParams @{ModuleName = 'Az.Accounts'; RequiredVersion = '2.7.4' } Get-Module Az.Accounts -ListAvailable | Limit-ModulePath $installTempPath | Select-Object -ExpandProperty Version @@ -556,7 +551,7 @@ Describe 'Install-ModuleFast' -Tag 'E2E' { } It 'specific requiredVersion when newer version is present' { Install-ModuleFast @imfParams 'Az.Accounts' - Install-ModuleFast @imfParams @{ ModuleName = 'Az.Accounts'; RequiredVersion = '2.7.4' } + Install-ModuleFast @imfParams @{ModuleName = 'Az.Accounts'; RequiredVersion = '2.7.4' } $installedVersions = Get-Module Az.Accounts -ListAvailable | Limit-ModulePath $installTempPath | Select-Object -ExpandProperty Version @@ -566,7 +561,7 @@ Describe 'Install-ModuleFast' -Tag 'E2E' { } It 'Installs when Maximumversion is lower than currently installed' { Install-ModuleFast @imfParams 'Az.Accounts' - Install-ModuleFast @imfParams @{ ModuleName = 'Az.Accounts'; MaximumVersion = '2.7.3' } + Install-ModuleFast @imfParams @{ModuleName = 'Az.Accounts'; MaximumVersion = '2.7.3' } Get-Module Az.Accounts -ListAvailable | Limit-ModulePath $installTempPath | Select-Object -ExpandProperty Version @@ -574,9 +569,11 @@ Describe 'Install-ModuleFast' -Tag 'E2E' { } It 'Only installs once when Update is specified and latest has not changed' { Install-ModuleFast @imfParams 'Az.Accounts' -Update - Install-ModuleFast @imfParams 'Az.Accounts' -Update -Debug *>&1 + $debugpreference = 'continue' + Install-ModuleFast @imfParams 'Az.Accounts' -Update *>&1 | Select-String 'best remote candidate matches what is locally installed' - | Should -Not -BeNullOrEmpty + | Should -BeLike '*best remote candidate matches what is locally installed*' + $debugpreference = 'silentlycontinue' } It 'Only installs once when Update is specified and latest has not changed for multiple modules' { Install-ModuleFast @imfParams 'Az.Compute', 'Az.CosmosDB' -Update @@ -588,12 +585,12 @@ Describe 'Install-ModuleFast' -Tag 'E2E' { Install-ModuleFast @imfParams 'Plaster=1.1.1' Install-ModuleFast @imfParams 'Plaster=1.1.3' $actual = Install-ModuleFast @imfParams 'Plaster' -Update -PassThru - $actual.ModuleVersion | Should -Be '1.1.4' + $actual.ModuleVersion | Should -BeGreaterThan '1.1.3' } It 'Updates only dependent module that requires update' { - Install-ModuleFast @imfParams @{ ModuleName = 'Az.Accounts'; RequiredVersion = '2.10.2' } - Install-ModuleFast @imfParams @{ ModuleName = 'Az.Compute'; RequiredVersion = '5.0.0' } + Install-ModuleFast @imfParams @{ModuleName = 'Az.Accounts'; RequiredVersion = '2.10.2' } + Install-ModuleFast @imfParams @{ModuleName = 'Az.Compute'; RequiredVersion = '5.0.0' } Get-Module Az.Accounts -ListAvailable | Limit-ModulePath $installTempPath | Select-Object -ExpandProperty Version @@ -668,22 +665,22 @@ Describe 'Install-ModuleFast' -Tag 'E2E' { It 'Errors trying to install prerelease over regular module' { Install-ModuleFast @imfParams 'PrereleaseTest=0.0.1' { Install-ModuleFast @imfParams 'PrereleaseTest=0.0.1-prerelease' } - | Should -Throw '*is newer than the requested prerelease version*' + | Should -Throw '*is newer than the requested version*' } It 'Errors trying to install older prerelease over regular module' { Install-ModuleFast @imfParams 'PrereleaseTest=0.0.1' { Install-ModuleFast @imfParams 'PrereleaseTest=0.0.1-prerelease' } - | Should -Throw '*is newer than the requested prerelease version*' + | Should -Throw '*is newer than the requested version*' } It 'Installs regular module over prerelease module with warning' { Install-ModuleFast @imfParams 'PrereleaseTest=0.0.1-prerelease' Install-ModuleFast @imfParams 'PrereleaseTest=0.0.1' -WarningVariable actual *>&1 | Out-Null - $actual | Should -BeLike '*is newer than existing prerelease version*' + $actual | Should -BeLike '*is newer than existing version*' } It 'Installs newer prerelease with warning' { Install-ModuleFast @imfParams 'PrereleaseTest=0.0.1-aprerelease' Install-ModuleFast @imfParams 'PrereleaseTest=0.0.1-bprerelease' -WarningVariable actual *>&1 | Out-Null - $actual | Should -BeLike '*is newer than existing prerelease version*' + $actual | Should -BeLike '*is newer than existing version*' } It 'Doesnt install prerelease if same-version Prerelease already installed' { Install-ModuleFast @imfParams 'PrereleaseTest=0.0.1-prerelease' @@ -815,7 +812,7 @@ Describe 'Install-ModuleFast' -Tag 'E2E' { Remove-Item $imfParams.Destination -Recurse -Force New-Item -ItemType Directory -Path $imfParams.Destination -ErrorAction stop Install-ModuleFast @imfParams -CI - $PreReleaseManifest = "$($imfParams.Destination)\PreReleaseTest\0.0.1\PreReleaseTest.psd1" + $PreReleaseManifest = "$($imfParams.Destination)\PrereleaseTest\0.0.1\PrereleaseTest.psd1" Resolve-Path $PreReleaseManifest (Import-PowerShellDataFile $PreReleaseManifest).PrivateData.PSData.Prerelease @@ -824,7 +821,7 @@ Describe 'Install-ModuleFast' -Tag 'E2E' { } It 'Handles an incomplete installation' { - $incompleteItemPath = "$installTempPath\PreReleaseTest\0.0.1\.incomplete" + $incompleteItemPath = "$installTempPath\PrereleaseTest\0.0.1\.incomplete" Install-ModuleFast @imfParams -Specification 'PreReleaseTest=0.0.1' New-Item -ItemType File -Path $incompleteItemPath Install-ModuleFast @imfParams -Specification 'PreReleaseTest=0.0.1' -Update -WarningVariable actual 3>$null @@ -862,7 +859,334 @@ Describe 'Install-ModuleFast' -Tag 'E2E' { Describe 'Plan Parameter' { It 'Does not install if Plan is specified' { Install-ModuleFast @imfParams -Specification 'PrereleaseTest' -Plan | Should -Match 'PreReleaseTest' - Test-Path $installTempPath\PreReleaseTest | Should -BeFalse + Test-Path $installTempPath\PrereleaseTest | Should -BeFalse + } + } + + Describe 'LocalModuleFinder' { + It 'Skips and deletes .incomplete folders' { + # Install a module, then mark it incomplete and try to re-install + Install-ModuleFast @imfParams 'PrereleaseTest=0.0.1' + $moduleDir = Get-ChildItem $installTempPath\PrereleaseTest -Directory | Select-Object -First 1 + New-Item -Path (Join-Path $moduleDir.FullName '.incomplete') -ItemType File | Out-Null + + # Should ignore the incomplete install and re-download + $plan = Install-ModuleFast @imfParams 'PrereleaseTest=0.0.1' -Plan + $plan | Should -Not -BeNullOrEmpty + # The incomplete folder should have been deleted + Test-Path $moduleDir.FullName | Should -BeFalse + } + + It 'Detects module with 3-part version folder' { + Install-ModuleFast @imfParams 'PrereleaseTest=0.0.1' + # The folder should be 3-part (0.0.1) not 4-part + $versionFolder = Get-ChildItem $installTempPath\PrereleaseTest -Directory | Select-Object -First 1 + $versionFolder.Name | Should -Be '0.0.1' + # Second install should detect it and produce no plan + $plan = Install-ModuleFast @imfParams 'PrereleaseTest=0.0.1' -Plan + $plan | Should -BeNullOrEmpty + } + + It 'Handles missing manifest gracefully' { + Install-ModuleFast @imfParams 'PrereleaseTest=0.0.1' + $moduleDir = Get-ChildItem $installTempPath\PrereleaseTest -Directory | Select-Object -First 1 + # Remove the manifest to simulate corruption + Remove-Item (Join-Path $moduleDir.FullName '*.psd1') + # Should produce a plan since the local module is unreadable + $plan = Install-ModuleFast @imfParams 'PrereleaseTest=0.0.1' -Plan + $plan | Should -Not -BeNullOrEmpty + } + } + + Describe 'Duplicate Specification Warning' { + It 'Warns when same module is specified twice' { + $actual = Install-ModuleFast @imfParams 'PrereleaseTest', 'PrereleaseTest' -Plan -WarningVariable warnings 3>$null + $warnings | Should -BeLike '*specified twice*' + } + } + + Describe 'Prerelease Parameter' { + It 'Installs prerelease when -Prerelease is specified globally' { + $actual = Install-ModuleFast @imfParams 'PrereleaseTest' -Prerelease -PassThru + $actual | Should -HaveCount 1 + $actual.ModuleVersion | Should -Be '0.0.2-prerelease' + } + } + + Describe 'StrictSemVer Parameter' { + It 'StrictSemVer includes prereleases within exclusive upper bound' { + $actual = Install-ModuleFast @imfParams 'PrereleaseTest!<0.0.2' -StrictSemVer -Plan + $actual | Should -HaveCount 1 + $actual.ModuleVersion.IsPrerelease | Should -Be $true + } + It 'Without StrictSemVer excludes prereleases from exclusive upper bound' { + $actual = Install-ModuleFast @imfParams 'PrereleaseTest!<0.0.2' -Plan + $actual | Should -HaveCount 1 + $actual.ModuleVersion.IsPrerelease | Should -Be $false + } + } + + Describe 'ModuleFastInfo Pipeline' { + It 'Installs modules from piped Install-ModuleFast -Plan output' { + $plan = Install-ModuleFast 'PrereleaseTest=0.0.1' -Plan + $actual = $plan | Install-ModuleFast @imfParams -PassThru + $actual | Should -HaveCount 1 + $actual.Name | Should -Be 'PrereleaseTest' + Get-Item $installTempPath\PrereleaseTest\*\PrereleaseTest.psd1 | Should -Not -BeNullOrEmpty + } + } + + Describe 'Auto-Detect Spec Files' { + It 'Detects spec files in current directory when no args given' { + $specDir = Join-Path $testDrive 'autodetect' + New-Item -ItemType Directory $specDir | Out-Null + "@{ 'PrereleaseTest' = 'latest' }" | Out-File (Join-Path $specDir 'ModuleFast.requires.psd1') + Push-Location $specDir + try { + $actual = Install-ModuleFast @imfParams -Plan + $actual | Should -Not -BeNullOrEmpty + $actual.Name | Should -Contain 'PrereleaseTest' + } finally { + Pop-Location + } + } + } + + Describe 'Directory Path' { + It 'Installs from directory containing spec files' { + $SCRIPT:Mocks = Resolve-Path "$PSScriptRoot/Test/Mocks" + $actual = Install-ModuleFast @imfParams -Path $Mocks -Plan + $actual | Should -Not -BeNullOrEmpty + } + } +} + +Describe 'Clear-ModuleFastCache' { + It 'Runs without error' { + { Clear-ModuleFastCache } | Should -Not -Throw + } + It 'Clears cached entries so subsequent plans re-fetch' { + # Prime the cache + Install-ModuleFast 'PrereleaseTest=0.0.1' -Plan | Out-Null + Clear-ModuleFastCache + # Should still return a valid plan after cache flush + $actual = Install-ModuleFast 'PrereleaseTest=0.0.1' -Plan + $actual | Should -HaveCount 1 + } +} + +Describe 'ModuleFastSpec' { + Context 'Hashtable Constructor' { + It 'Creates from hashtable' { + $spec = [ModuleFastSpec]@{ ModuleName = 'Test'; ModuleVersion = '1.0.0' } + $spec.Name | Should -Be 'Test' + $spec.Min | Should -Be '1.0.0' + } + It 'Creates from hashtable with RequiredVersion' { + $spec = [ModuleFastSpec]@{ ModuleName = 'Test'; RequiredVersion = '2.0.0' } + $spec.Name | Should -Be 'Test' + $spec.Required | Should -Be '2.0.0' + } + It 'Creates from hashtable with Guid' { + $guid = [Guid]::NewGuid() + $spec = [ModuleFastSpec]@{ ModuleName = 'Test'; Guid = $guid; ModuleVersion = '1.0.0' } + $spec.Name | Should -Be 'Test' + $spec.Guid | Should -Be $guid + } + } + + Context 'SatisfiedBy' { + It 'Exact version satisfies required version spec' { + $spec = [ModuleFastSpec]'Test=1.0.0' + $spec.SatisfiedBy([NuGet.Versioning.NuGetVersion]::Parse('1.0.0')) | Should -Be $true + $spec.SatisfiedBy([NuGet.Versioning.NuGetVersion]::Parse('2.0.0')) | Should -Be $false + } + It 'Higher version satisfies minimum version spec' { + $spec = [ModuleFastSpec]'Test>=1.0.0' + $spec.SatisfiedBy([NuGet.Versioning.NuGetVersion]::Parse('1.0.0')) | Should -Be $true + $spec.SatisfiedBy([NuGet.Versioning.NuGetVersion]::Parse('2.0.0')) | Should -Be $true + $spec.SatisfiedBy([NuGet.Versioning.NuGetVersion]::Parse('0.9.0')) | Should -Be $false + } + It 'Version range upper exclusive excludes prereleases by default' { + $spec = [ModuleFastSpec]'Test:(,2.0.0)' + $spec.SatisfiedBy([NuGet.Versioning.NuGetVersion]::Parse('2.0.0-alpha')) | Should -Be $false + } + It 'Version range upper exclusive includes prereleases with StrictSemVer' { + $spec = [ModuleFastSpec]'Test:(,2.0.0)' + $spec.SatisfiedBy([NuGet.Versioning.NuGetVersion]::Parse('2.0.0-alpha'), $true) | Should -Be $true + } + It 'System.Version overload works' { + $spec = [ModuleFastSpec]'Test>=1.0.0' + $spec.SatisfiedBy([Version]'2.0.0.0') | Should -Be $true + $spec.SatisfiedBy([Version]'0.5.0.0') | Should -Be $false + } + } + + Context 'Overlap' { + It 'Overlapping ranges return true' { + $a = [ModuleFastSpec]'Test>=1.0.0' + $b = [ModuleFastSpec]'Test<=2.0.0' + $a.Overlap($b) | Should -Be $true + } + It 'Non-overlapping ranges return false' { + $a = [ModuleFastSpec]'Test:(,1.0.0)' + $b = [ModuleFastSpec]'Test>2.0.0' + $a.Overlap($b) | Should -Be $false + } + } + + Context 'Equality and Comparison' { + It 'Equal specs are equal' { + $a = [ModuleFastSpec]'Test=1.0.0' + $b = [ModuleFastSpec]'Test=1.0.0' + $a.Equals($b) | Should -Be $true + $a.GetHashCode() | Should -Be $b.GetHashCode() + } + It 'Different specs are not equal' { + $a = [ModuleFastSpec]'Test=1.0.0' + $b = [ModuleFastSpec]'Test=2.0.0' + $a.Equals($b) | Should -Be $false + } + It 'Name-only spec is not equal to versioned spec' { + $a = [ModuleFastSpec]'Test' + $b = [ModuleFastSpec]'Test=1.0.0' + $a.Equals($b) | Should -Be $false + } + } + + Context 'ToString' { + It 'Name only' { + ([ModuleFastSpec]'Test').ToString() | Should -Be 'Test' + } + It 'Required version' { + ([ModuleFastSpec]'Test=1.0.0').ToString() | Should -Be 'Test(1.0.0)' + } + It 'With Guid' { + $guid = '7c279caf-00bc-40ae-a1ed-184ad07be1b0' + $spec = [ModuleFastSpec]::new( + [ModuleSpecification]@{ ModuleName = 'Test'; Guid = $guid; ModuleVersion = '1.0.0' } + ) + $spec.ToString() | Should -BeLike "*$guid*" + } + } + + Context 'PreRelease property' { + It 'Returns false for stable version' { + ([ModuleFastSpec]'Test=1.0.0').PreRelease | Should -Be $false + } + It 'Returns true for prerelease version' { + ([ModuleFastSpec]'Test=1.0.0-beta').PreRelease | Should -Be $true + } + It 'Returns true for bang-prefixed name' { + ([ModuleFastSpec]'!Test').PreRelease | Should -Be $true + } + It 'Returns true for bang-suffixed name' { + ([ModuleFastSpec]'Test!').PreRelease | Should -Be $true + } + } + + Context 'Error Cases' { + It 'Throws on empty name' { + { [ModuleFastSpec]'' } | Should -Throw + } + It 'Throws on null name' { + { [ModuleFastSpec]::new($null) } | Should -Throw + } + It 'Throws on invalid hashtable string' { + { [ModuleFastSpec]'@{ModuleName="Test"; BadKey="Bad"}' } | Should -Throw '*not valid ModuleSpecification syntax*' + } + } +} + +Describe 'ModuleFastInfo' { + It 'Properties are set correctly' { + $info = [ModuleFastInfo]::new('TestModule', '1.2.3', 'https://example.com/package') + $info.Name | Should -Be 'TestModule' + $info.ModuleVersion | Should -Be '1.2.3' + $info.Location | Should -Be 'https://example.com/package' + $info.IsLocal | Should -Be $false + } + It 'IsLocal is true for file URIs' { + $tempFile = Join-Path $TestDrive 'test.nupkg' + New-Item $tempFile -ItemType File -Force | Out-Null + $info = [ModuleFastInfo]::new('TestModule', '1.0.0', "file:///$tempFile") + $info.IsLocal | Should -Be $true + } + It 'PreRelease is true for prerelease version' { + $info = [ModuleFastInfo]::new('TestModule', '1.0.0-beta', 'https://example.com/package') + $info.PreRelease | Should -Be $true + } + It 'PreRelease is false for stable version' { + $info = [ModuleFastInfo]::new('TestModule', '1.0.0', 'https://example.com/package') + $info.PreRelease | Should -Be $false + } + It 'ToString formats correctly' { + $info = [ModuleFastInfo]::new('TestModule', '1.2.3', 'https://example.com/package') + $info.ToString() | Should -Be 'TestModule(1.2.3)' + } + It 'Casts to ModuleSpecification' { + $info = [ModuleFastInfo]::new('TestModule', '1.2.3', 'https://example.com/package') + $ms = [ModuleSpecification]$info + $ms.Name | Should -Be 'TestModule' + $ms.RequiredVersion | Should -Be '1.2.3.0' + } + It 'Equal instances have same hash code' { + $a = [ModuleFastInfo]::new('Test', '1.0.0', 'https://example.com/a') + $b = [ModuleFastInfo]::new('Test', '1.0.0', 'https://example.com/a') + $a.Equals($b) | Should -Be $true + $a.GetHashCode() | Should -Be $b.GetHashCode() + } + It 'Different instances are not equal' { + $a = [ModuleFastInfo]::new('Test', '1.0.0', 'https://example.com/a') + $b = [ModuleFastInfo]::new('Test', '2.0.0', 'https://example.com/b') + $a.Equals($b) | Should -Be $false + } + It 'Formats as a table with name version and location' { + $info = [ModuleFastInfo]::new('TestModule', '1.2.3', 'https://example.com/package') + $rendered = $info | Format-Table | Out-String -Width 200 + + $rendered | Should -Match 'Name\s+ModuleVersion\s+Location' + $rendered | Should -Match 'TestModule\s+1\.2\.3\s+https://example\.com/package' + $rendered | Should -Not -Match 'RequiredModules|Guid|IsLocal|PreRelease' + } +} + +Describe 'Install-ModuleFast -Plan' -Tag 'E2E' { + BeforeAll { + $SCRIPT:__existingPSModulePath2 = $env:PSModulePath + $env:PSModulePath = $testDrive + } + AfterAll { + $env:PSModulePath = $SCRIPT:__existingPSModulePath2 + } + + Context 'Destination Parameter' { + It 'Uses -Destination to check for already installed modules' { + $destDir = Join-Path $testDrive $(New-Guid) + New-Item -ItemType Directory $destDir | Out-Null + # First plan should include the module + $plan1 = Install-ModuleFast 'PrereleaseTest=0.0.1' -Destination $destDir -NoPSModulePathUpdate -NoProfileUpdate -Plan + $plan1 | Should -HaveCount 1 + # Install the module there + Install-ModuleFast 'PrereleaseTest=0.0.1' -Destination $destDir -NoPSModulePathUpdate -NoProfileUpdate + # Second plan should find it installed + $plan2 = Install-ModuleFast 'PrereleaseTest=0.0.1' -Destination $destDir -NoPSModulePathUpdate -NoProfileUpdate -Plan + $plan2 | Should -BeNullOrEmpty + } + } + + Context 'Update Parameter' { + It 'Returns plan when -Update is specified even if locally satisfied' { + $destDir = Join-Path $testDrive $(New-Guid) + New-Item -ItemType Directory $destDir | Out-Null + $env:PSModulePath = $destDir + Install-ModuleFast 'PrereleaseTest=0.0.1' -Destination $destDir -NoPSModulePathUpdate -NoProfileUpdate + # Without update, should be satisfied + $plan = Install-ModuleFast 'PrereleaseTest' -Destination $destDir -NoPSModulePathUpdate -NoProfileUpdate -Plan + $plan | Should -BeNullOrEmpty + # With update, should check for newer + $plan = Install-ModuleFast 'PrereleaseTest' -Destination $destDir -NoPSModulePathUpdate -NoProfileUpdate -Update -Plan + $plan | Should -BeNullOrEmpty -Because 'PrereleaseTest 0.0.1 is already the latest stable version' } } } diff --git a/ModuleFastBuild.requires.psd1 b/ModuleFastBuild.requires.psd1 index 7bf0407..cedde96 100644 --- a/ModuleFastBuild.requires.psd1 +++ b/ModuleFastBuild.requires.psd1 @@ -1,6 +1,6 @@ @{ 'InvokeBuild' = ':5.*' - 'Pester' = ':[5.5, 6.0)' + 'Pester' = ':[6.0, 7.0)' 'PlatyPS' = ':[0.14.2, 1.0)' 'Microsoft.PowerShell.PSResourceGet' = ':1.*' } diff --git a/Source/Console/Console.csproj b/Source/Console/Console.csproj new file mode 100644 index 0000000..d665c47 --- /dev/null +++ b/Source/Console/Console.csproj @@ -0,0 +1,19 @@ + + + Exe + net10.0 + enable + enable + modulefast + ModuleFast.Console + true + true + true + + + + + + + + diff --git a/Source/Console/NuGetProtocol.Aot.xml b/Source/Console/NuGetProtocol.Aot.xml new file mode 100644 index 0000000..6f4b9db --- /dev/null +++ b/Source/Console/NuGetProtocol.Aot.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Source/Console/Program.cs b/Source/Console/Program.cs new file mode 100644 index 0000000..825815c --- /dev/null +++ b/Source/Console/Program.cs @@ -0,0 +1,257 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; + +using ModuleFast; + +string source = "https://pwsh.gallery/index.json"; +string? destination = null; +string? specFilePath = null; +List specInputs = []; +bool update = false; +bool prerelease = false; +bool ci = false; +bool plan = false; +bool destinationOnly = false; +bool strictSemVer = false; +int timeout = 30; +int throttleLimit = Environment.ProcessorCount; +string ciLockFilePath = "requires.lock.json"; +string? username = null; +string? password = null; + +static bool IsOptionToken(string value) => + value.StartsWith('-') && value is not "-"; + +// Parse arguments +for (int i = 0; i < args.Length; i++) +{ + switch (args[i].ToLowerInvariant()) + { + case "-source" or "--source": + source = args[++i]; + break; + case "-destination" or "--destination" or "-d": + destination = args[++i]; + break; + case "-path" or "--path" or "-p": + specFilePath = args[++i]; + break; + case "-spec" or "--spec": + if (i + 1 >= args.Length || IsOptionToken(args[i + 1])) + { + Console.Error.WriteLine("Error: -spec requires at least one module specification value."); + return 1; + } + + // Accept a series of values after -spec until the next option token. + while (i + 1 < args.Length && !IsOptionToken(args[i + 1])) + specInputs.Add(args[++i]); + break; + case "-update" or "--update": + update = true; + break; + case "-prerelease" or "--prerelease": + prerelease = true; + break; + case "-ci" or "--ci": + ci = true; + break; + case "-plan" or "--plan": + plan = true; + break; + case "-destinationonly" or "--destinationonly": + destinationOnly = true; + break; + case "-strictsemver" or "--strictsemver": + strictSemVer = true; + break; + case "-timeout" or "--timeout": + timeout = int.Parse(args[++i]); + break; + case "-throttlelimit" or "--throttlelimit": + throttleLimit = int.Parse(args[++i]); + break; + case "-lockfilepath" or "--lockfilepath": + ciLockFilePath = args[++i]; + break; + case "-username" or "--username" or "-u": + username = args[++i]; + break; + case "-password" or "--password": + password = args[++i]; + break; + case "-help" or "--help" or "-h" or "-?": + PrintUsage(); + return 0; + default: + // Positional: if it exists on disk, treat as spec path; otherwise treat as inline module spec. + if (File.Exists(args[i]) || Directory.Exists(args[i])) + specFilePath ??= args[i]; + else + specInputs.Add(args[i]); + break; + } +} + +// Resolve destination +destination ??= PathHelper.GetPSDefaultModulePath(allUsers: false) + ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "powershell", "Modules"); + +if (!Directory.Exists(destination)) + Directory.CreateDirectory(destination); + +// Build credential if provided +NetworkCredential? credential = null; +if (username != null && password != null) + credential = new NetworkCredential(username, password); + +// Create HttpClient +HttpClient httpClient = ModuleFastClient.Create(credential, timeout); + +using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeout * 10)); +CancellationToken ct = cts.Token; + +// Collect specs +var specs = new HashSet(); + +if (specInputs.Count > 0) +{ + foreach (string specInput in specInputs) + { + try + { + specs.Add(new ModuleFastSpec(specInput)); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: Invalid -spec value '{specInput}'. {ex.Message}"); + return 1; + } + } +} + +if (specFilePath != null) +{ + if (Directory.Exists(specFilePath)) + { + foreach (string file in SpecFileReader.FindRequiredSpecFiles(specFilePath)) + { + foreach (ModuleFastSpec spec in SpecFileReader.ConvertFromRequiredSpec(file)) + specs.Add(spec); + } + } + else + { + foreach (ModuleFastSpec spec in SpecFileReader.ConvertFromRequiredSpec(specFilePath)) + specs.Add(spec); + } +} +else +{ + // Auto-detect spec files in current directory + if (specInputs.Count == 0 && ci && File.Exists(ciLockFilePath)) + { + Console.WriteLine($"Using lockfile: {ciLockFilePath}"); + foreach (ModuleFastSpec spec in SpecFileReader.ConvertFromRequiredSpec(ciLockFilePath)) + specs.Add(spec); + update = false; + } + else if (specInputs.Count == 0) + { + IEnumerable specFiles = SpecFileReader.FindRequiredSpecFiles(Environment.CurrentDirectory); + foreach (string file in specFiles) + { + Console.WriteLine($"Found specfile: {file}"); + foreach (ModuleFastSpec spec in SpecFileReader.ConvertFromRequiredSpec(file)) + specs.Add(spec); + } + } +} + +if (specs.Count == 0) +{ + Console.Error.WriteLine("Error: No module specifications found."); + return 1; +} + +if (update) ModuleFastCache.Instance.Clear(); + +// Plan +Console.WriteLine($"Planning installation of {specs.Count} module specification(s)..."); + +string[] modulePaths = destinationOnly + ? [destination] + : Environment.GetEnvironmentVariable("PSModulePath") + ?.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) ?? []; + +var planner = new ModuleFastPlanner(source); +HashSet planSet = await planner.GetPlan(specs, modulePaths, update, prerelease, strictSemVer, destinationOnly, ct); +ModuleFastInfo[] installPlan = planSet.ToArray(); + +if (installPlan.Length == 0) +{ + Console.WriteLine("All module specifications are already satisfied."); + return 0; +} + +if (plan) +{ + Console.WriteLine($"Plan: {installPlan.Length} module(s) to install:"); + foreach (ModuleFastInfo? info in installPlan) + Console.WriteLine($" {info.Name} {info.ModuleVersion}"); + return 0; +} + +// Install +Console.WriteLine($"Installing {installPlan.Length} module(s) to {destination}..."); +ModuleFastInstaller installer = new ModuleFastInstaller(source); +List installed = await installer.InstallModules(installPlan, destination, update, ct, maxConcurrency: throttleLimit); + +Console.WriteLine($"Installed {installed.Count} module(s)."); + +if (ci) +{ + var lockFile = new Dictionary(); + foreach (ModuleFastInfo? m in installPlan) + lockFile[m.Name] = m.ModuleVersion.ToString(); + + string json = JsonSerializer.Serialize(lockFile, ConsoleJsonContext.Default.DictionaryStringString); + File.WriteAllText(ciLockFilePath, json); + Console.WriteLine($"Lockfile written to {ciLockFilePath}"); +} + +return 0; + +static void PrintUsage() +{ + Console.WriteLine(""" + modulefast - Fast PowerShell module installer + + Usage: modulefast [options] [path] + + Options: + -path, -p Path to spec file or directory + -spec One or more inline module specs (e.g. Az Az<6.0.0) + -destination, -d Module install destination + -source NuGet v3 source URL + -update Force update check (ignore cache) + -prerelease Include prerelease versions + -ci CI mode (use/write lockfile) + -plan Show plan without installing + -destinationonly Only check destination for existing modules + -strictsemver Use strict SemVer matching + -timeout HTTP timeout (default: 30) + -throttlelimit Max concurrent downloads + -lockfilepath CI lockfile path (default: requires.lock.json) + -username, -u Credential username + -password Credential password + -help, -h Show this help + """); +} + +[JsonSerializable(typeof(Dictionary))] +[JsonSourceGenerationOptions(WriteIndented = true)] +internal partial class ConsoleJsonContext : JsonSerializerContext { } \ No newline at end of file diff --git a/Source/Core/CmdletInteraction.cs b/Source/Core/CmdletInteraction.cs new file mode 100644 index 0000000..b69d30e --- /dev/null +++ b/Source/Core/CmdletInteraction.cs @@ -0,0 +1,28 @@ +namespace ModuleFast; + +/// +/// Abstraction for interacting with the cmdlet, allows the implementation to support multiple threads. +/// +public interface CmdletInteraction +{ + /// Requests confirmation from the user for a potentially destructive action. + Task Confirm(string target, string action); + + /// Gets a variable value from the host (e.g. $profile). + object? GetVariable(string name); + + /// Gets the host name (e.g. "ConsoleHost", "Visual Studio Code Host"). + string? HostName { get; } + + /// Writes a debug message to the host. + void Debug(string message); + + /// Writes a verbose message to the host. + void Verbose(string message); + + /// Writes a warning message to the host. + void Warning(string message); + + /// Writes an informational message to the host. + void Info(string message, string[]? tags = null); +} \ No newline at end of file diff --git a/Source/Core/Core.csproj b/Source/Core/Core.csproj new file mode 100644 index 0000000..804d698 --- /dev/null +++ b/Source/Core/Core.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + ModuleFastCore + ModuleFast + true + + + + + + + + + + + diff --git a/Source/Core/LocalModuleFinder.cs b/Source/Core/LocalModuleFinder.cs new file mode 100644 index 0000000..4bb707e --- /dev/null +++ b/Source/Core/LocalModuleFinder.cs @@ -0,0 +1,222 @@ +using System.Management.Automation; +using System.Text.RegularExpressions; + +using NuGet.Versioning; + +namespace ModuleFast; + +public static partial class LocalModuleFinder +{ + [GeneratedRegex(@"^\d+\.\d+\.\d+\.\d+$", RegexOptions.Compiled)] + private static partial Regex FourPartVersionRegex(); + + private static string? FindSinglePathOrThrow(IEnumerable matches, string ambiguityPrefix) + { + using IEnumerator enumerator = matches.GetEnumerator(); + if (!enumerator.MoveNext()) + return null; + + string first = enumerator.Current; + if (!enumerator.MoveNext()) + return first; + + List allMatches = [first, enumerator.Current]; + while (enumerator.MoveNext()) + allMatches.Add(enumerator.Current); + + throw new InvalidOperationException($"{ambiguityPrefix}: {string.Join(", ", allMatches)}"); + } + + /// + /// 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 || + FourPartVersionRegex().IsMatch(version.OriginalVersion ?? "")) + 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 async Task FindLocalModule( + ModuleFastSpec spec, + string[]? modulePaths, + bool update, + IDictionary? bestCandidates, + bool strictSemVer, + CancellationToken ct = default, + CmdletInteraction? logger = null) + { + if (modulePaths == null || modulePaths.Length == 0) + { + logger?.Warning("No PSModulePaths found. If you are doing isolated testing you can disregard this."); + return null; + } + + foreach (string modulePath in modulePaths) + { + ct.ThrowIfCancellationRequested(); + + if (!Directory.Exists(modulePath)) + { + logger?.Debug($"{spec}: Skipping PSModulePath {modulePath} - Configured but does not exist."); + continue; + } + + // Case-insensitive search for module base dir + string? moduleBaseDir = FindSinglePathOrThrow( + Directory.EnumerateDirectories(modulePath, spec.Name, + new EnumerationOptions { MatchCasing = MatchCasing.CaseInsensitive }), + $"{spec.Name} folder is ambiguous, please delete one"); + + if (moduleBaseDir == null) + { + logger?.Debug($"{spec}: Skipping PSModulePath {modulePath} - Does not have this module."); + continue; + } + + List<(Version version, string path)> candidatePaths = []; + string manifestName = $"{spec.Name}.psd1"; + + NuGetVersion? required = spec.Required; + if (required != null) + { + Version moduleVersion = ResolveFolderVersion(required); + string moduleFolder = Path.Combine(moduleBaseDir, moduleVersion.ToString()); + if (Directory.Exists(moduleFolder)) + candidatePaths.Add((moduleVersion, moduleFolder)); + } + else + { + // Enumerate versioned sub-folders + foreach (string folder in Directory.EnumerateDirectories(moduleBaseDir)) + { + string leafName = Path.GetFileName(folder); + if (!Version.TryParse(leafName, out Version? version)) + { + logger?.Debug($"Could not parse {folder} in {moduleBaseDir} as a valid version."); + continue; + } + + if (spec.Max != null && version > spec.Max.Version) + { + logger?.Debug($"{spec}: Skipping {folder} - above the upper bound"); + continue; + } + + if (spec.Min != null) + { + string originalParts = (spec.Min.OriginalVersion ?? "").Split('-')[0]; + Version minVersion = Version.TryParse(originalParts, out Version? parsedBase) && parsedBase.Revision == -1 + ? parsedBase + : spec.Min.Version; + if (version < minVersion) + { + logger?.Debug($"{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) + { + string? classicManifestPath = FindSinglePathOrThrow( + Directory.EnumerateFiles(moduleBaseDir, manifestName, + new EnumerationOptions { MatchCasing = MatchCasing.CaseInsensitive }), + $"{moduleBaseDir} manifest is ambiguous"); + if (classicManifestPath != null) + { + ModuleFastInfo classicManifest = await PSDataFileReader.ImportModuleManifest(classicManifestPath, ct, logger).ConfigureAwait(false); + Version manifestVersion = classicManifest.ModuleVersion.Version; + + logger?.Debug($"{spec}: Found classic module {manifestVersion} at {moduleBaseDir}"); + candidatePaths.Add((manifestVersion, moduleBaseDir)); + } + } + + if (candidatePaths.Count == 0) + { + logger?.Debug($"{spec}: Skipping PSModulePath {modulePath} - No installed versions matched the spec."); + continue; + } + + foreach ((Version? version, string? folder) in candidatePaths) + { + if (File.Exists(Path.Combine(folder, ".incomplete"))) + { + logger?.Warning($"{spec}: Incomplete installation detected at {folder}. Deleting and ignoring."); + try + { + Directory.Delete(folder, true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + logger?.Warning($"{spec}: Failed to delete incomplete installation at {folder}: {ex.Message}"); + } + continue; + } + + string? manifestPath = FindSinglePathOrThrow( + Directory.EnumerateFiles(folder, manifestName, + new EnumerationOptions { MatchCasing = MatchCasing.CaseInsensitive }), + $"{folder} manifest is ambiguous"); + + if (manifestPath == null) + { + logger?.Warning($"{spec}: Found candidate folder {folder} but no {manifestName} manifest found. This may be a corrupt module."); + continue; + } + + ModuleFastInfo manifestCandidate; + try + { + manifestCandidate = await PSDataFileReader + .ImportModuleManifest(manifestPath, ct, logger).ConfigureAwait(false); + } + catch (Exception ex) + { + logger?.Warning($"{spec}: Failed to read manifest at {manifestPath}: {ex.Message}"); + continue; + } + + if (spec.Guid != Guid.Empty && manifestCandidate.Guid != spec.Guid) + { + logger?.Warning($"{spec}: Module at {folder} GUID {manifestCandidate.Guid} does not match spec GUID {spec.Guid}."); + continue; + } + + NuGetVersion candidateVersion = manifestCandidate.ModuleVersion; + + if (spec.SatisfiedBy(candidateVersion, strictSemVer)) + { + if (update && spec.Max != candidateVersion) + { + logger?.Debug($"{spec}: Skipping {candidateVersion} because -Update was specified and version does not exactly meet upper bound."); + if (bestCandidates != null && + (!bestCandidates.TryGetValue(spec, out ModuleFastInfo? existing) || + manifestCandidate.ModuleVersion > existing.ModuleVersion)) + { + logger?.Debug($"{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/Core/ModuleFastCache.cs b/Source/Core/ModuleFastCache.cs new file mode 100644 index 0000000..c90d344 --- /dev/null +++ b/Source/Core/ModuleFastCache.cs @@ -0,0 +1,20 @@ +using System.Collections.Concurrent; + +namespace ModuleFast; + +public class ModuleFastCache +{ + private readonly ConcurrentDictionary> _cache = new(StringComparer.OrdinalIgnoreCase); + public static readonly ModuleFastCache Instance = new(); + + /// + /// Atomically returns the cached task for , or adds and returns a new task + /// produced by . Using + /// eliminates the get-then-set race that would otherwise fire duplicate in-flight HTTP requests for the + /// same URI when multiple dependency-resolution tasks complete simultaneously. + /// + public Task GetOrAdd(string key, Func> factory) => + _cache.GetOrAdd(key, factory); + + public void Clear() => _cache.Clear(); +} \ No newline at end of file diff --git a/Source/Core/ModuleFastClient.cs b/Source/Core/ModuleFastClient.cs new file mode 100644 index 0000000..57ddde2 --- /dev/null +++ b/Source/Core/ModuleFastClient.cs @@ -0,0 +1,142 @@ +using System.Management.Automation; +using System.Net; +using System.Net.Http.Headers; +using System.Text; + +using Polly; +using Polly.Retry; + +namespace ModuleFast; + +/// Helper to build a from standard HTTP_PROXY / HTTPS_PROXY / NO_PROXY environment variables. +internal static class EnvironmentProxy +{ + internal static IWebProxy? Create() + { + // Prefer lowercase (convention on Linux), fall back to uppercase (common on Windows/CI). + string? httpsProxy = Environment.GetEnvironmentVariable("HTTPS_PROXY") + ?? Environment.GetEnvironmentVariable("https_proxy"); + string? httpProxy = Environment.GetEnvironmentVariable("HTTP_PROXY") + ?? Environment.GetEnvironmentVariable("http_proxy"); + + string? proxyUrl = httpsProxy ?? httpProxy; + if (string.IsNullOrWhiteSpace(proxyUrl)) return null; + + var proxy = new WebProxy(proxyUrl); + + string? noProxy = Environment.GetEnvironmentVariable("NO_PROXY") + ?? Environment.GetEnvironmentVariable("no_proxy"); + if (!string.IsNullOrWhiteSpace(noProxy)) + { + proxy.BypassList = noProxy + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(host => "^" + System.Text.RegularExpressions.Regex.Escape(host) + .Replace("\\*", ".*") + "$") + .ToArray(); + } + + return proxy; + } +} + +public static class ModuleFastClient +{ + /// Default number of retry attempts for transient HTTP failures. + public const int DefaultMaxRetries = 3; + + public static HttpClient Create(NetworkCredential? credential = null, int timeoutSeconds = 30, int maxRetries = DefaultMaxRetries) + { + AppContext.SetSwitch("System.Net.SocketsHttpHandler.Http3Support", true); + SocketsHttpHandler handler = new SocketsHttpHandler + { + // Allow more parallel connections to the same host (registry + CDN). + MaxConnectionsPerServer = 30, + // Allow an additional TCP connection when all HTTP/2 streams on the first are consumed. + EnableMultipleHttp2Connections = true, + InitialHttp2StreamWindowSize = 16777216, + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli, + // Recycle connections after 5 minutes so stale long-lived connections don't silently fail. + PooledConnectionLifetime = TimeSpan.FromMinutes(5), + }; + + // Honour HTTP_PROXY / HTTPS_PROXY / NO_PROXY environment variables on all platforms. + IWebProxy? envProxy = EnvironmentProxy.Create(); + if (envProxy != null) + { + handler.Proxy = envProxy; + handler.UseProxy = true; + } + + ResilienceHandler resilienceHandler = new ResilienceHandler(CreateResiliencePipeline(maxRetries)) + { + InnerHandler = handler + }; + + HttpClient client = new HttpClient(resilienceHandler) + { + Timeout = TimeSpan.FromSeconds(timeoutSeconds), + DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher + }; + client.DefaultRequestHeaders.UserAgent.TryParseAdd("ModuleFast (github.com/JustinGrote/ModuleFast)"); + if (credential != null) + client.DefaultRequestHeaders.Authorization = ToAuthHeader(credential); + return client; + } + + private static ResiliencePipeline CreateResiliencePipeline(int maxRetries) + { + return new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = maxRetries, + BackoffType = DelayBackoffType.Exponential, + UseJitter = true, + Delay = TimeSpan.FromMilliseconds(200), + ShouldHandle = static args => ValueTask.FromResult(ShouldRetry(args.Outcome)), + DelayGenerator = static args => + { + // Respect Retry-After header on 429 responses + if (args.Outcome.Result?.StatusCode == HttpStatusCode.TooManyRequests && + args.Outcome.Result.Headers.RetryAfter is { } retryAfter) + { + TimeSpan? delay = retryAfter.Delta + ?? (retryAfter.Date.HasValue ? retryAfter.Date.Value - DateTimeOffset.UtcNow : (TimeSpan?)null); + if (delay.HasValue && delay.Value > TimeSpan.Zero) + return ValueTask.FromResult(delay.Value); + } + return ValueTask.FromResult(null); // use default backoff + } + }) + .Build(); + } + + private static bool ShouldRetry(Outcome outcome) + { + // Retry on transient exceptions (timeouts, connection resets, etc.) + if (outcome.Exception is HttpRequestException or TaskCanceledException) + return true; + + if (outcome.Result is null) + return false; + + return outcome.Result.StatusCode is + HttpStatusCode.TooManyRequests or // 429 + HttpStatusCode.RequestTimeout or // 408 + HttpStatusCode.InternalServerError or // 500 + HttpStatusCode.BadGateway or // 502 + HttpStatusCode.ServiceUnavailable or // 503 + HttpStatusCode.GatewayTimeout; // 504 + } + + public static AuthenticationHeaderValue ToAuthHeader(PSCredential credential) + { + return ToAuthHeader(credential.GetNetworkCredential()); + } + + public static AuthenticationHeaderValue ToAuthHeader(NetworkCredential credential) + { + string token = Convert.ToBase64String( + Encoding.UTF8.GetBytes($"{credential.UserName}:{credential.Password}")); + return new AuthenticationHeaderValue("Basic", token); + } +} \ No newline at end of file diff --git a/Source/Core/ModuleFastInfo.cs b/Source/Core/ModuleFastInfo.cs new file mode 100644 index 0000000..fdc1b01 --- /dev/null +++ b/Source/Core/ModuleFastInfo.cs @@ -0,0 +1,32 @@ +using System.Collections; +using System.Collections.ObjectModel; +using System.Management.Automation; +using System.Management.Automation.Language; +using System.Reflection; + +using Microsoft.PowerShell.Commands; + +using NuGet.Versioning; + +namespace ModuleFast; + +/// +/// Represent a module either available in a package repository or installed locally. This is a lightweight representation of a module, and does not require loading the module into the current session. +/// +public sealed record ModuleFastInfo(string Name, NuGetVersion ModuleVersion, Uri Location) +{ + public ReadOnlyCollection RequiredModules { get; init; } = []; + public Guid Guid { get; init; } = Guid.Empty; + public bool IsLocal => Location.IsFile; + public bool PreRelease => ModuleVersion.IsPrerelease || ModuleVersion.HasMetadata; + + public static implicit operator ModuleSpecification(ModuleFastInfo info) => + new(new Hashtable + { + { "ModuleName", info.Name }, + { "RequiredVersion", info.ModuleVersion.Version }, + { "Guid", info.Guid } + }); + + public override string ToString() => $"{Name}({ModuleVersion})"; +} \ No newline at end of file diff --git a/Source/Core/ModuleFastInstaller.cs b/Source/Core/ModuleFastInstaller.cs new file mode 100644 index 0000000..2cfea89 --- /dev/null +++ b/Source/Core/ModuleFastInstaller.cs @@ -0,0 +1,264 @@ +using System.Collections; +using System.Collections.Concurrent; +using System.IO.Compression; +using System.Management.Automation; + +using NuGet.Common; +using NuGet.Configuration; +using NuGet.Protocol.Core.Types; +using NuGet.Versioning; +namespace ModuleFast; + +public class ModuleFastInstaller +{ + private readonly SourceRepository _sourceRepository; + + /// + /// Performs a case-insensitive search for a .psd1 manifest whose base name matches + /// inside . + /// Returns the full path on success, or when no match is found. + /// + private static string? FindManifestPath(string directory, string moduleName) + { + EnumerationOptions options = new EnumerationOptions { MatchCasing = MatchCasing.CaseInsensitive }; + string[] matches = Directory.GetFiles(directory, "*.psd1", options) + .Where(f => string.Equals(Path.GetFileNameWithoutExtension(f), moduleName, + StringComparison.OrdinalIgnoreCase)) + .ToArray(); + return matches.Length == 1 ? matches[0] + : matches.Length > 1 ? throw new InvalidOperationException($"Ambiguous manifest in {directory}: {string.Join(", ", matches)}") + : null; + } + + public ModuleFastInstaller(string source) + { + _sourceRepository = new SourceRepository( + new PackageSource(source), + Repository.Provider.GetCoreV3()); + } + + /// + /// Installs all in parallel from an async stream, + /// capping concurrency at simultaneous operations. + /// + public async Task> InstallModules( + IAsyncEnumerable modules, + string destination, + bool update, + CancellationToken ct, + CmdletInteraction? cmdlet = null, + int maxConcurrency = 0, + Action? onModuleInstalled = null) + { + if (maxConcurrency <= 0) + maxConcurrency = -1; // Default to unbounded concurrency + + FindPackageByIdResource findPackageByIdResource = await _sourceRepository + .GetResourceAsync(ct) + .ConfigureAwait(false); + + ConcurrentBag results = []; + ParallelOptions opts = new() + { + MaxDegreeOfParallelism = maxConcurrency, + CancellationToken = ct + }; + + await Parallel.ForEachAsync(modules, opts, async (m, ct) => + { + ModuleFastInfo? result = await InstallSingleAsync(m, destination, update, findPackageByIdResource, ct, cmdlet) + .ConfigureAwait(false); + if (result != null) + { + results.Add(result); + onModuleInstalled?.Invoke(result); + } + }).ConfigureAwait(false); + + return results.ToList(); + } + + /// + /// Installs all in parallel using + /// , capping concurrency at + /// simultaneous operations. + /// + public async Task> InstallModules( + IEnumerable modules, + string destination, + bool update, + CancellationToken ct, + CmdletInteraction? cmdlet = null, + int maxConcurrency = 0, + Action? onModuleInstalled = null) + { + return await InstallModules(modules.ToAsyncEnumerable(), destination, update, ct, cmdlet, maxConcurrency, onModuleInstalled) + .ConfigureAwait(false); + } + + private async Task InstallSingleAsync( + ModuleFastInfo module, + string destination, + bool update, + FindPackageByIdResource findPackageByIdResource, + CancellationToken ct, + CmdletInteraction? cmdlet) + { + string installPath = Path.Combine(destination, module.Name, + LocalModuleFinder.ResolveFolderVersion(module.ModuleVersion).ToString()); + string installIndicatorPath = Path.Combine(installPath, ".incomplete"); + + if (File.Exists(installIndicatorPath)) + { + cmdlet?.Warning($"{module}: Incomplete installation found at {installPath}. Will delete and retry."); + Directory.Delete(installPath, true); + } + + if (Directory.Exists(installPath)) + { + string existingManifestPath = FindManifestPath(installPath, module.Name) + ?? throw new FileNotFoundException( + $"{module}: Existing module folder found at {installPath} but no manifest matching '{module.Name}.psd1' could be found.", + Path.Combine(installPath, $"{module.Name}.psd1")); + + ModuleFastInfo existingManifest = await PSDataFileReader + .ImportModuleManifest(existingManifestPath, ct, cmdlet) + .ConfigureAwait(false); + + if (module.Guid != Guid.Empty && existingManifest.Guid != module.Guid) + throw new InvalidOperationException($"{module}: Expected {module.Guid} but found {existingManifest.Guid}."); + + NuGetVersion existingVersion = existingManifest.ModuleVersion; + + if (module.ModuleVersion == existingVersion) + { + if (update) + { + cmdlet?.Debug($"{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?.Warning($"{module}: Planned version {module.ModuleVersion} is newer than existing version {existingVersion} so we will overwrite."); + Directory.Delete(installPath, true); + } + + cmdlet?.Verbose($"{module}: Downloading from {module.Location}"); + if (module.Location == null) + throw new InvalidOperationException($"{module}: No Download Link found. This is a bug."); + + using SourceCacheContext cacheContext = new() + { + DirectDownload = true + }; + using MemoryStream packageStream = new(); + bool packageFound = await findPackageByIdResource.CopyNupkgToStreamAsync( + module.Name, + module.ModuleVersion, + packageStream, + cacheContext, + NullLogger.Instance, + ct).ConfigureAwait(false); + if (!packageFound) + throw new InvalidOperationException($"{module}: package content was not found in the configured source."); + + packageStream.Position = 0; + + Directory.CreateDirectory(installPath); + + // WriteThrough ensures the .incomplete marker reaches the OS immediately โ€” + // critical because it guards against partial installations on crash. + await using (FileStream indicatorFs = new FileStream(installIndicatorPath, new FileStreamOptions + { + Mode = FileMode.Create, + Access = FileAccess.Write, + Share = FileShare.None, + Options = FileOptions.WriteThrough | FileOptions.Asynchronous, + })) { /* zero-byte sentinel; just creating the file is enough */ } + + // ZipFile.ExtractToDirectoryAsync (.NET 10) uses async file I/O internally and + // includes built-in zip-slip protection, replacing the custom ExtractZipAsync method. + await ZipFile.ExtractToDirectoryAsync(packageStream, installPath, overwriteFiles: false, ct).ConfigureAwait(false); + + string manifestPath = FindManifestPath(installPath, module.Name) + ?? throw new FileNotFoundException( + $"{module}: Could not find manifest matching '{module.Name}.psd1' in {installPath}.", + Path.Combine(installPath, $"{module.Name}.psd1")); + + // Verify the module was properly installed by reading the manifest and checking the version. + ModuleFastInfo installedModuleManifest = await PSDataFileReader + .ImportModuleManifest(manifestPath, ct, cmdlet) + .ConfigureAwait(false); + + if (module.Guid != Guid.Empty && installedModuleManifest.Guid != module.Guid) + throw new InvalidOperationException($"{module}: Expected {module.Guid} but found {installedModuleManifest.Guid}."); + + string moduleManifestFolderVersion = LocalModuleFinder.ResolveFolderVersion(installedModuleManifest.ModuleVersion).ToString(); + string originalModuleVersion = Path.GetFileName(installPath); + if (originalModuleVersion != moduleManifestFolderVersion) + { + cmdlet?.Debug($"{module}: Module Manifest folder version {moduleManifestFolderVersion} differs from package folder version {originalModuleVersion}, moving..."); + string installPathRoot = Path.GetDirectoryName(installPath)!; + string newInstallPath = Path.Combine(installPathRoot, moduleManifestFolderVersion); + + if (Directory.Exists(newInstallPath)) + Directory.Delete(newInstallPath, true); + + Directory.Move(installPath, newInstallPath); + installPath = newInstallPath; + + // Update indicator path + installIndicatorPath = Path.Combine(installPath, ".incomplete"); + // WriteThrough + Asynchronous: durable write that doesn't block the thread on I/O + await using FileStream origVerFs = new FileStream( + Path.Combine(installPath, ".originalModuleVersion"), + new FileStreamOptions + { + Mode = FileMode.Create, + Access = FileAccess.Write, + Share = FileShare.None, + Options = FileOptions.WriteThrough | FileOptions.Asynchronous, + }); + await using StreamWriter origVerWriter = new StreamWriter(origVerFs); + await origVerWriter.WriteLineAsync(originalModuleVersion).ConfigureAwait(false); + } + else + { + cmdlet?.Debug($"{module}: Verified module manifest version matched, no action needed."); + } + + module = module with { ModuleVersion = installedModuleManifest.ModuleVersion }; + + // Clean up NuGet files โ€” use EnumerateFileSystemEntries to avoid buffering the full listing + cmdlet?.Debug($"{module}: Cleaning up NuGet files in {installPath}"); + if (string.IsNullOrEmpty(installPath)) + throw new InvalidOperationException("ModuleDestination was not set. This is a bug."); + + foreach (string item in Directory.EnumerateFileSystemEntries(installPath)) + { + string 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); + + cmdlet?.Verbose($"{module}: Successfully installed to {installPath}"); + + module = module with { Location = new Uri(installPath) }; + return module; + } +} \ No newline at end of file diff --git a/Source/Core/ModuleFastPlanner.cs b/Source/Core/ModuleFastPlanner.cs new file mode 100644 index 0000000..1ddfb0b --- /dev/null +++ b/Source/Core/ModuleFastPlanner.cs @@ -0,0 +1,198 @@ +using System.Collections.Concurrent; +using System.Net; + +using NuGet.Common; +using NuGet.Configuration; +using NuGet.Protocol; +using NuGet.Protocol.Core.Types; + +using NuGet.Versioning; + +namespace ModuleFast; + +public class ModuleFastPlanner( + string source +) +{ + private readonly SourceRepository _sourceRepository = new( + new PackageSource(source), + Repository.Provider.GetCoreV3()); + + public async Task> GetPlan( + IEnumerable specs, + string[] modulePaths, + bool update, + bool prerelease, + bool strictSemVer, + bool destinationOnly, + CancellationToken ct, + CmdletInteraction? cmdlet = null, + Func? onModulePlanned = null) + { + PackageMetadataResource metadataResource = await _sourceRepository + .GetResourceAsync(ct) + .ConfigureAwait(false); + + ConcurrentDictionary modulesToInstall = []; + ConcurrentDictionary bestLocalCandidates = []; + ConcurrentDictionary enqueuedSpecs = []; + ConcurrentQueue pendingSpecs = []; + + static string GetSpecKey(ModuleFastSpec spec) => + $"{spec.Name.ToLowerInvariant()}|{spec.Guid:D}|{spec.VersionRange}|pre:{spec.PreRelease}"; + + foreach (ModuleFastSpec spec in specs) + { + if (enqueuedSpecs.TryAdd(GetSpecKey(spec), 0)) + pendingSpecs.Enqueue(spec); + } + + while (!pendingSpecs.IsEmpty) + { + List batch = []; + while (pendingSpecs.TryDequeue(out ModuleFastSpec? spec)) + batch.Add(spec); + + if (batch.Count == 0) + continue; + + await Parallel.ForEachAsync(batch, ct, async (currentSpec, token) => + { + cmdlet?.Verbose($"{currentSpec}: Evaluating Module Specification"); + + ModuleFastInfo? localMatch = await LocalModuleFinder.FindLocalModule( + currentSpec, + modulePaths, + update, + bestLocalCandidates, + strictSemVer, + token, + cmdlet).ConfigureAwait(false); + if (localMatch != null && !update) + { + cmdlet?.Debug($"{localMatch}: ๐ŸŽฏ FOUND satisfying version {localMatch.ModuleVersion} at {localMatch.Location}. Skipping remote search."); + return; + } + + cmdlet?.Debug($"{currentSpec}: ๐Ÿ” No installed versions matched. Will check remotely."); + + if (currentSpec.Guid != Guid.Empty) + cmdlet?.Warning($"{currentSpec}: A GUID constraint was found. GUIDs will only be verified after installation."); + + cmdlet?.Debug($"{currentSpec}: Processing Response"); + + IEnumerable allMetadata; + try + { + using SourceCacheContext cacheContext = new(); + allMetadata = await metadataResource.GetMetadataAsync( + currentSpec.Name, + includePrerelease: true, + includeUnlisted: false, + cacheContext, + NullLogger.Instance, + token).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); + } + + IPackageSearchMetadata? selectedPackage = allMetadata + .Where(m => m.Identity != null) + .OrderByDescending(m => m.Identity.Version) + .FirstOrDefault(m => + { + NuGetVersion candidate = m.Identity.Version; + if ((candidate.IsPrerelease || candidate.HasMetadata) && !(currentSpec.PreRelease || prerelease)) + { + cmdlet?.Debug($"{currentSpec}: skipping candidate {candidate} - prerelease not requested."); + return false; + } + + if (!currentSpec.SatisfiedBy(candidate, strictSemVer)) + return false; + + cmdlet?.Debug($"{currentSpec}: Found satisfying version {candidate}."); + return true; + }); + + if (selectedPackage == 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(selectedPackage.Tags) && + selectedPackage.Tags.Contains("ItemType:Script", StringComparison.OrdinalIgnoreCase)) + throw new NotImplementedException($"{currentSpec}: Script installations are currently not supported."); + + ModuleFastInfo selectedModule = new ModuleFastInfo( + selectedPackage.Identity.Id, + selectedPackage.Identity.Version, + new Uri(source)); + + if (currentSpec.Guid != Guid.Empty) + selectedModule = selectedModule with { Guid = currentSpec.Guid }; + + if (update && bestLocalCandidates.TryGetValue(currentSpec, out ModuleFastInfo? bestLocal) && + bestLocal.ModuleVersion == selectedModule.ModuleVersion) + { + cmdlet?.Debug($"{selectedModule}: -Update specified and best remote candidate matches what is locally installed. Skipping install."); + return; + } + + if (!modulesToInstall.TryAdd(selectedModule, 0)) + { + cmdlet?.Debug($"{selectedModule} already exists in the install plan. Skipping..."); + return; + } + + if (onModulePlanned != null) + await onModulePlanned(selectedModule, token).ConfigureAwait(false); + + cmdlet?.Verbose($"{selectedModule}: Added to install plan"); + + var allDeps = selectedPackage.DependencySets? + .SelectMany(g => g.Packages ?? []) ?? []; + + foreach (var dep in allDeps) + { + VersionRange depRange = dep.VersionRange ?? VersionRange.All; + ModuleFastSpec depSpec = new ModuleFastSpec(dep.Id, depRange); + + ModuleFastInfo? existing = modulesToInstall.Keys + .Where(m => string.Equals(m.Name, depSpec.Name, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(m => m.ModuleVersion) + .FirstOrDefault(); + if (existing != null && depSpec.SatisfiedBy(existing.ModuleVersion, strictSemVer)) + { + cmdlet?.Debug($"Dependency {depSpec} satisfied by existing planned install {existing}"); + continue; + } + + ModuleFastInfo? depLocal = await LocalModuleFinder.FindLocalModule( + depSpec, + modulePaths, + update, + bestLocalCandidates, + strictSemVer, + token, + cmdlet).ConfigureAwait(false); + if (depLocal != null) + { + cmdlet?.Debug($"FOUND local module {depLocal.Name} {depLocal.ModuleVersion} satisfies {depSpec}. Skipping..."); + continue; + } + + cmdlet?.Debug($"{currentSpec}: Queueing dependency {depSpec}"); + if (enqueuedSpecs.TryAdd(GetSpecKey(depSpec), 0)) + pendingSpecs.Enqueue(depSpec); + } + }).ConfigureAwait(false); + } + + return modulesToInstall.Keys.ToHashSet(); + } +} \ No newline at end of file diff --git a/Source/Core/ModuleFastSpec.cs b/Source/Core/ModuleFastSpec.cs new file mode 100644 index 0000000..e13794d --- /dev/null +++ b/Source/Core/ModuleFastSpec.cs @@ -0,0 +1,329 @@ +using System.Collections; + +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 ModuleSpecification? 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.Contains('!')) + { + preReleaseName = true; + name = name.Replace("!", ""); + } + + string moduleName; + VersionRange range; + + if (name.Contains(">=", StringComparison.Ordinal)) + { + string[] parts = name.Split(">=", 2); + moduleName = parts[0].Trim('!'); + range = NuGetVersion.TryParse(parts[1], out NuGetVersion? lower) + ? new VersionRange(lower, true) + : throw new ArgumentException($"Invalid version '{parts[1]}'"); + } + else if (name.Contains("<=", StringComparison.Ordinal)) + { + string[] parts = name.Split("<=", 2); + moduleName = parts[0].Trim('!'); + range = NuGetVersion.TryParse(parts[1], out NuGetVersion? upper) + ? new VersionRange(null, false, upper, true) + : throw new ArgumentException($"Invalid version '{parts[1]}'"); + } + else if (name.Contains('=')) + { + string[] parts = name.Split('=', 2); + moduleName = parts[0].Trim('!'); + range = VersionRange.Parse($"[{parts[1]}]"); + } + else if (name.Contains(':')) + { + string[] parts = name.Split(':', 2); + moduleName = parts[0].Trim('!'); + range = VersionRange.Parse(parts[1]); + } + else if (name.Contains('>')) + { + string[] parts = name.Split('>', 2); + moduleName = parts[0].Trim('!'); + range = NuGetVersion.TryParse(parts[1], out NuGetVersion? lowerExcl) + ? new VersionRange(lowerExcl, false) + : throw new ArgumentException($"Invalid version '{parts[1]}'"); + } + else if (name.Contains('<')) + { + string[] parts = name.Split('<', 2); + moduleName = parts[0].Trim('!'); + range = NuGetVersion.TryParse(parts[1], out NuGetVersion? upperExcl) + ? new VersionRange(null, false, upperExcl, false) + : throw new ArgumentException($"Invalid version '{parts[1]}'"); + } + else + { + moduleName = name.Trim('!'); + range = VersionRange.All; + } + + _name = moduleName; + _versionRange = range; + _guid = Guid.Empty; + _preReleaseName = preReleaseName; + } + + public ModuleFastSpec(string name, string requiredVersion) + : this(name, requiredVersion, 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 = Guid.TryParse(guid, out Guid g) ? g : 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 = Guid.Empty; + } + + public ModuleFastSpec(ModuleSpecification spec) + { + (_name, _versionRange, _guid, _preReleaseName) = InitFromModuleSpec(spec); + } + + public ModuleFastSpec(Hashtable hashtable) + : this(new ModuleSpecification(hashtable)) + { + } + + // --- 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(); + + VersionRange range = new VersionRange( + string.IsNullOrEmpty(minStr) ? null : NuGetVersion.Parse(minStr), + true, + string.IsNullOrEmpty(maxStr) ? null : NuGetVersion.Parse(maxStr), + true, + null, + $"ModuleSpecification: {spec}" + ); + + Guid guid = spec.Guid ?? Guid.Empty; + return (spec.Name, range, guid, false); + } + + // --- Methods --- + + public bool SatisfiedBy(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) + { + VersionRange range = _versionRange; + bool strictSatisfies = range.IsFloating + ? range.Float!.Satisfies(version) + : range.Satisfies(version); + + if (strictSemVer) + return strictSatisfies; + + if (range.MaxVersion == null) + return strictSatisfies; + + NuGetVersion max = range.MaxVersion; + NuGetVersion? 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) + { + VersionRange 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 != 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) + { + Hashtable props = new() + { + ["ModuleName"] = spec.Name + }; + + if (spec.Guid != 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 Version(0, 0); + } + + return new ModuleSpecification(props); + } +} \ No newline at end of file diff --git a/Source/Core/PSDataFileReader.cs b/Source/Core/PSDataFileReader.cs new file mode 100644 index 0000000..2fc3220 --- /dev/null +++ b/Source/Core/PSDataFileReader.cs @@ -0,0 +1,172 @@ +using System.Collections; +using System.Management.Automation; +using System.Management.Automation.Language; +using System.Management.Automation.Runspaces; +using System.Text.RegularExpressions; + +using NuGet.Versioning; + +namespace ModuleFast; + +public static class PSDataFileReader +{ + + public static async Task ImportModuleManifest(string path, CancellationToken cancelToken = default, CmdletInteraction? cmdlet = null) + { + try + { + return await ParseModuleManifest(await File.ReadAllTextAsync(path, cancelToken), path, cmdlet); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to read module manifest at {path}: {ex.Message}", ex); + } + } + + public static async Task ParseModuleManifest(string content, string location, CmdletInteraction? cmdlet = null) + { + Hashtable hashtable = Parse(content, location, cmdlet: cmdlet); + // Get the prerelease tag from PrivateData + string? prereleaseTag = null; + if (hashtable["PrivateData"] is Hashtable privateData) + { + privateData = new Hashtable(privateData, StringComparer.InvariantCultureIgnoreCase); + + if (privateData["PSData"] is Hashtable psData) + { + psData = new Hashtable(psData, StringComparer.InvariantCultureIgnoreCase); + + if (psData["Prerelease"] is string tag && !string.IsNullOrWhiteSpace(tag)) + { + prereleaseTag = tag; + } + } + } + + string name = Path.GetFileNameWithoutExtension(location) ?? throw new InvalidDataException($"Invalid module manifest: Name is missing or could not be determined from the file name {location}"); + object? moduleVersionObject = hashtable["ModuleVersion"]; + string moduleVersion = moduleVersionObject switch + { + string moduleVersionString => moduleVersionString, + Version moduleVersionValue => moduleVersionValue.ToString(), + _ => throw new InvalidDataException("Invalid module manifest: ModuleVersion is missing or not a supported type.") + }; + string? prerelease = prereleaseTag; + Guid guid = hashtable["Guid"] switch + { + Guid guidValue => guidValue, + string guidString when Guid.TryParse(guidString, out Guid parsedGuid) => parsedGuid, + _ => Guid.Empty + }; + + string nugetVersionString = prerelease is not null ? $"{moduleVersion}-{prerelease}" : moduleVersion; + + NuGetVersion version = NuGetVersion.Parse(nugetVersionString); + + return new ModuleFastInfo(name, version, new Uri(location)) + { + Guid = guid + }; + } + + public static async Task Import(string path, CancellationToken cancelToken = default, CmdletInteraction? cmdlet = null) + { + try + { + return Parse(await File.ReadAllTextAsync(path, cancelToken), path, cancelToken, cmdlet); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to read data file at {path}: {ex.Message}", ex); + } + } + + public static Hashtable Parse(string content, string path, CancellationToken cancelToken = default, CmdletInteraction? cmdlet = null) + { + cmdlet?.Debug($"Parsing PowerShell data file: {path}"); + + ScriptBlockAst ast = Parser.ParseInput(content, out _, out ParseError[] errors); + if (errors.Length > 0) + throw new InvalidOperationException($"Failed to parse module manifest: {string.Join(", ", errors.Select(e => e.Message))}"); + + HashtableAst? hashtableAst = ast.Find(static a => a is HashtableAst, false) as HashtableAst; + + if (hashtableAst is null) + throw new InvalidOperationException("Invalid module manifest: a top-level hashtable was not found."); + + Hashtable? hashtable; + try + { + // Get the key values for Name, ModuleVersion, and Guid from the hashtable + hashtable = hashtableAst.SafeGetValue() as Hashtable; + } + catch (InvalidOperationException ex) when (ex.Message.Contains("Cannot generate a PowerShell object for a ScriptBlock evaluating dynamic expressions")) + { + cmdlet?.Debug($"{path} is a Manifest with dynamic expressions. Attempting to safe evaluate..."); + Runspace? previousRunspace = Runspace.DefaultRunspace; + Runspace? temporaryRunspace = null; + var scriptBlock = ScriptBlock.Create(content); + // Check if a runspace exists on the thread, if not create a temporary one to evaluate the scriptblock + if (Runspace.DefaultRunspace is null) + { + temporaryRunspace = RunspaceFactory.CreateRunspace(); + temporaryRunspace.ThreadOptions = PSThreadOptions.UseCurrentThread; + temporaryRunspace.Open(); + Runspace.DefaultRunspace = temporaryRunspace; + } + + try + { + scriptBlock.CheckRestrictedLanguage([], ["PSEdition", "PSScriptRoot"], true); + cancelToken.ThrowIfCancellationRequested(); + object? evaluatedDataFile = scriptBlock.InvokeReturnAsIs(); + hashtable = evaluatedDataFile switch + { + Hashtable evaluatedHashtable => evaluatedHashtable, + IDictionary evaluatedDictionary => new Hashtable(evaluatedDictionary), + PSObject { BaseObject: Hashtable baseHashtable } => baseHashtable, + PSObject { BaseObject: IDictionary baseDictionary } => new Hashtable(baseDictionary), + _ => null + }; + cancelToken.ThrowIfCancellationRequested(); + } + finally + { + Runspace.DefaultRunspace = previousRunspace; + temporaryRunspace?.Dispose(); + } + } + + if (hashtable is null) + throw new InvalidOperationException("Invalid module manifest: the top-level hashtable could not be evaluated."); + + // We need to make sure the hashtable is case-insensitive, as module manifests are case-insensitive. + return new Hashtable(hashtable, StringComparer.InvariantCultureIgnoreCase); + } + + /// + /// Fast scan of a .psd1 file to read only the ModuleVersion line without full parse. + /// Uses to hint sequential access to the OS. + /// + public static Version? TryReadModuleVersionFast(string manifestPath) + { + if (!File.Exists(manifestPath)) return null; + FileStreamOptions streamOptions = new() + { + Mode = FileMode.Open, + Access = FileAccess.Read, + Share = FileShare.Read, + Options = FileOptions.SequentialScan, + }; + using StreamReader reader = new StreamReader(manifestPath, streamOptions); + string? line; + while ((line = reader.ReadLine()) != null) + { + Match m = Regex.Match(line, + @"\s*ModuleVersion\s*=\s*['""](?.+?)['""]"); + if (m.Success && Version.TryParse(m.Groups["version"].Value, out Version? v)) + return v; + } + return null; + } +} \ No newline at end of file diff --git a/Source/Core/PathHelper.cs b/Source/Core/PathHelper.cs new file mode 100644 index 0000000..4964f05 --- /dev/null +++ b/Source/Core/PathHelper.cs @@ -0,0 +1,194 @@ +namespace ModuleFast; + +public enum InstallScope { CurrentUser, AllUsers } + +public static class PathHelper +{ + public static string? GetPSDefaultModulePath(bool allUsers) + { + try + { + string userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string[] modulePaths = (Environment.GetEnvironmentVariable("PSModulePath") ?? "") + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Select(p => + { + try { return Path.GetFullPath(p); } + catch { return p; } + }) + .ToArray(); + + if (modulePaths.Length > 0) + { + if (allUsers) + { + string? allUsersPath = modulePaths.FirstOrDefault(p => + !p.StartsWith(userProfile, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(allUsersPath)) return allUsersPath; + } + else + { + string? currentUserPath = modulePaths.FirstOrDefault(p => + p.StartsWith(userProfile, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(currentUserPath)) return currentUserPath; + } + } + + if (OperatingSystem.IsWindows()) + { + if (allUsers) + { + string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + return string.IsNullOrWhiteSpace(programFiles) + ? null + : Path.Combine(programFiles, "PowerShell", "Modules"); + } + + string documents = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + return string.IsNullOrWhiteSpace(documents) + ? null + : Path.Combine(documents, "PowerShell", "Modules"); + } + + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!allUsers) + { + return string.IsNullOrWhiteSpace(home) + ? null + : Path.Combine(home, ".local", "share", "powershell", "Modules"); + } + + return "/usr/local/share/powershell/Modules"; + } + catch + { + return null; + } + } + + public static async Task AddDestinationToPSModulePath(string destination, bool noProfileUpdate, CmdletInteraction cmdlet) + { + static string GetDefaultProfilePath() + { + string userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return OperatingSystem.IsWindows() + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "PowerShell", "profile.ps1") + : Path.Combine(userProfile, ".config", "powershell", "profile.ps1"); + } + + destination = Path.GetFullPath(destination); + + string[] modulePaths = (Environment.GetEnvironmentVariable("PSModulePath") ?? "") + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); + + if (modulePaths.Contains(destination, StringComparer.OrdinalIgnoreCase)) + { + cmdlet.Debug($"Destination '{destination}' is already in PSModulePath."); + return; + } + + cmdlet.Verbose($"Updating PSModulePath to include {destination}"); + Environment.SetEnvironmentVariable("PSModulePath", + destination + Path.PathSeparator + Environment.GetEnvironmentVariable("PSModulePath")); + + if (noProfileUpdate) + { + cmdlet.Debug("Skipping profile update because -NoProfileUpdate was specified."); + return; + } + + object? profileValue = cmdlet.GetVariable("profile"); + string? myProfile = profileValue?.ToString(); + + // VSCode's PowerShell extension uses a custom host whose $profile.CurrentUserAllHosts + // may be null or incorrect. Fall back to the standard filesystem location. + if (string.IsNullOrEmpty(myProfile)) + { + cmdlet.Verbose("CurrentUserAllHosts profile path is not set."); + } + else if (string.Equals(cmdlet.HostName, "Visual Studio Code Host", StringComparison.OrdinalIgnoreCase)) + { + cmdlet.Verbose("Visual Studio Code Host detected; resolving profile path from filesystem."); + myProfile = GetDefaultProfilePath(); + } + + if (string.IsNullOrEmpty(myProfile)) return; + + if (!Path.IsPathRooted(myProfile) || string.IsNullOrWhiteSpace(Path.GetDirectoryName(myProfile))) + { + cmdlet.Verbose($"Profile path '{myProfile}' is not fully qualified; resolving profile path from filesystem."); + myProfile = GetDefaultProfilePath(); + } + + if (!File.Exists(myProfile)) + { + if (!await cmdlet.Confirm(myProfile, $"Allow ModuleFast to work by creating a profile at {myProfile}.").ConfigureAwait(false)) + return; + + cmdlet.Verbose("User All Hosts profile not found, creating one."); + Directory.CreateDirectory(Path.GetDirectoryName(myProfile) ?? "."); + // Use FileStream with explicit options to avoid unnecessary buffering for a new empty file + using FileStream _ = new FileStream(myProfile, new FileStreamOptions + { + Mode = FileMode.CreateNew, + Access = FileAccess.Write, + Share = FileShare.None, + Options = FileOptions.WriteThrough, + }); + } + + // Use relative destination if possible + string displayDestination = destination; + string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + foreach (string? basePath in new[] { localAppData, home }) + { + if (string.IsNullOrWhiteSpace(basePath)) continue; + string rel = Path.GetRelativePath(basePath, destination); + if (rel != destination) + { + displayDestination = "$([environment]::GetFolderPath('LocalApplicationData'))" + + Path.DirectorySeparatorChar + rel; + break; + } + } + + string profileLine = $"if (\"{displayDestination}\" -notin ($env:PSModulePath.split([IO.Path]::PathSeparator))) {{ $env:PSModulePath = \"{displayDestination}\" + $([IO.Path]::PathSeparator + $env:PSModulePath) }} #Added by ModuleFast."; + + // Use FileStreamOptions with SequentialScan for reading (the profile is read top-to-bottom once) + string profileContent; + using (FileStream fs = new FileStream(myProfile, new FileStreamOptions + { + Mode = FileMode.Open, + Access = FileAccess.Read, + Share = FileShare.Read, + Options = FileOptions.SequentialScan, + })) + using (StreamReader reader = new StreamReader(fs)) + profileContent = reader.ReadToEnd(); + + if (!profileContent.Contains(profileLine)) + { + if (!await cmdlet.Confirm( + myProfile, + $"Allow ModuleFast to add {destination} to PSModulePath on startup." + ).ConfigureAwait(false)) return; + + cmdlet.Verbose($"Adding {destination} to profile {myProfile}"); + // WriteThrough flushes each write directly to the OS, avoiding buffered-write data loss on crash + using FileStream appendFs = new FileStream(myProfile, new FileStreamOptions + { + Mode = FileMode.Append, + Access = FileAccess.Write, + Share = FileShare.Read, + Options = FileOptions.WriteThrough, + }); + using StreamWriter writer = new StreamWriter(appendFs); + writer.Write("\n\n" + profileLine + "\n"); + } + else + { + cmdlet.Verbose($"PSModulePath {destination} already in profile, skipping..."); + } + } +} \ No newline at end of file diff --git a/Source/Core/ResilienceHandler.cs b/Source/Core/ResilienceHandler.cs new file mode 100644 index 0000000..b253286 --- /dev/null +++ b/Source/Core/ResilienceHandler.cs @@ -0,0 +1,19 @@ +using Polly; + +namespace ModuleFast; + +/// +/// A DelegatingHandler that wraps HTTP requests with a Polly resilience pipeline +/// for retry logic with exponential backoff and Retry-After header support. +/// +internal sealed class ResilienceHandler(ResiliencePipeline pipeline) : DelegatingHandler +{ + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + return await pipeline.ExecuteAsync( + async ct => await base.SendAsync(request, ct).ConfigureAwait(false), + cancellationToken + ).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/Source/Core/SpecFileReader.cs b/Source/Core/SpecFileReader.cs new file mode 100644 index 0000000..6dc0b93 --- /dev/null +++ b/Source/Core/SpecFileReader.cs @@ -0,0 +1,378 @@ +using System.Collections; +using System.Collections.ObjectModel; +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) + { + string resolvedPath = Path.GetFullPath(path); + string[] requireFiles = Directory.GetFiles(resolvedPath, "*.requires.*") + .Where(f => + { + string 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, + CmdletInteraction? cmdlet = null) + { + object spec = ReadRequiredSpecFile(requiredSpecPath, cmdlet).GetAwaiter().GetResult(); + return ConvertFromObject(spec, fileType, cmdlet); + } + + private static ModuleFastSpec[] ConvertFromObject(object? requiredSpec, SpecFileType fileType, CmdletInteraction? logger) + { + 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 IDictionary dict) + { + if (fileType == SpecFileType.AutoDetect) + fileType = SelectRequiredSpecFileType(dict); + + return fileType switch + { + SpecFileType.PSDepend => ConvertFromPSDepend(dict, logger), + SpecFileType.PSResourceGet => ConvertFromPSResourceGet(dict, logger), + _ => ConvertFromModuleFastDict(dict) + }; + } + + // 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) + { + List results = []; + foreach (DictionaryEntry kv in dict) + { + string key = kv.Key?.ToString() ?? throw new InvalidDataException("Keys must be strings"); + string 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 VersionRange? 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, CmdletInteraction? logger) + { + Dictionary initialSpec = new Dictionary(StringComparer.OrdinalIgnoreCase); + Hashtable specCopy = new Hashtable(StringComparer.OrdinalIgnoreCase); + foreach (DictionaryEntry kv in spec) + specCopy[kv.Key?.ToString() ?? ""] = kv.Value; + + if (specCopy.Contains("PSDependOptions")) + { + logger?.Debug("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) + logger?.Warning("PSDepend Parse: Target in PSDependOptions is not currently supported."); + } + specCopy.Remove("PSDependOptions"); + } + + foreach (DictionaryEntry kv in specCopy) + { + string key = kv.Key?.ToString() ?? ""; + if (string.IsNullOrEmpty(key)) continue; + + if (key.Contains("/")) + { + logger?.Debug($"PSDepend Parse: Skipping Unsupported GitHub module {key}"); + continue; + } + + Match colonMatch = _psDependExtendedKeyRegex.Match(key); + if (colonMatch.Success) + { + if (colonMatch.Groups[1].Value != "PSGalleryModule") + { + logger?.Debug($"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") + { + logger?.Debug($"PSDepend Parse: Skipping {key} because DependencyType is not PSGalleryModule"); + continue; + } + + string version = extValue["Version"]?.ToString() ?? "latest"; + string 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, CmdletInteraction? logger) + { + Dictionary initialSpec = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (DictionaryEntry kv in spec) + { + string 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"); + + string version = extValue["Version"]?.ToString() ?? "latest"; + + if (extValue["Prerelease"] != null) + { + logger?.Debug($"PSResourceGet Parse: Prerelease detected for {key}"); + key = $"!{key}"; + } + if (extValue["Repository"] != null) + logger?.Warning($"PSResourceGet Parse: Repository specification for {key} is not currently supported."); + + initialSpec[key] = version; + } + + List results = []; + foreach (KeyValuePair kv in initialSpec) + { + if (kv.Value == "latest") + { + results.Add(new ModuleFastSpec(kv.Key)); + continue; + } + + string 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 async Task ReadRequiredSpecFile(string requiredSpecPath, CmdletInteraction? cmdlet) + { + if (Uri.TryCreate(requiredSpecPath, UriKind.Absolute, out Uri? uri) && + uri.Scheme is "http" or "https") + { + var proxy = EnvironmentProxy.Create(); + var handler = new SocketsHttpHandler(); + if (proxy != null) + { + handler.Proxy = proxy; + handler.UseProxy = true; + } + using HttpClient client = new(handler); + string content = await client.GetStringAsync(requiredSpecPath).ConfigureAwait(false); + if (content.AsSpan().TrimStart().StartsWith("@{".AsSpan())) + { + return PSDataFileReader.Parse(content, requiredSpecPath, default, cmdlet); + } + return JsonSerializer.Deserialize(content, _jsonOpts)!; + } + + string resolvedPath = Path.GetFullPath(requiredSpecPath); + string extension = Path.GetExtension(resolvedPath).ToLowerInvariant(); + + if (extension == ".psd1") + { + Hashtable dataFile = await PSDataFileReader.Import(resolvedPath, default, cmdlet); + if (dataFile.ContainsKey("ModuleVersion")) + { + cmdlet?.Debug("Detected a Module Manifest, evaluating RequiredModules"); + object? reqModules = dataFile["RequiredModules"]; + if (reqModules == null) + throw new InvalidDataException("The manifest does not have a RequiredModules key so ModuleFast does not know what this module requires."); + + return ConvertRequiredModulesToSpecs(reqModules); + } + else + { + cmdlet?.Debug("Did not detect a module manifest, passing through as-is"); + return dataFile; + } + } + + if (extension is ".ps1" or ".psm1") + { + cmdlet?.Debug("PowerShell Script/Module file detected, checking for #Requires"); + ScriptBlockAst ast = Parser.ParseFile(resolvedPath, out _, out _); + ModuleSpecification[]? 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") + { + string content = File.ReadAllText(resolvedPath); + JsonElement json = JsonSerializer.Deserialize(content, _jsonOpts); + if (json.ValueKind == JsonValueKind.Array) + { + string[] strings = json.EnumerateArray().Select(e => e.GetString() ?? "").ToArray(); + return strings; + } + // Convert to dictionary + Hashtable dict = new Hashtable(StringComparer.OrdinalIgnoreCase); + foreach (JsonProperty 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 IEnumerable moduleInfos) + return moduleInfos.Select(moduleInfo => new ModuleFastSpec(new ModuleSpecification(new Hashtable + { + { "ModuleName", moduleInfo.Name }, + { "Guid", moduleInfo.Guid }, + { "RequiredVersion", moduleInfo.Version } + }))).ToArray(); + + if (requiredModules is object[] arr) + { + List specs = []; + foreach (object 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 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 diff --git a/Source/PowerShell/Commands/Base/TaskCmdlet.cs b/Source/PowerShell/Commands/Base/TaskCmdlet.cs new file mode 100644 index 0000000..6fa9c3d --- /dev/null +++ b/Source/PowerShell/Commands/Base/TaskCmdlet.cs @@ -0,0 +1,635 @@ +namespace System.Management.Automation; + +using System.Collections; +using System.Collections.Concurrent; +using System.Threading; + +using ModuleFast; + +public class TaskCmdletInteractor(TaskCmdlet cmdlet) : CmdletInteraction +{ + public string? HostName => cmdlet.Host.Name; + + public Task Confirm(string target, string action) => cmdlet.Confirm(target, action); + + public object? GetVariable(string name) => cmdlet.Exec(() => cmdlet.GetVariableValue(name)); + + public void Debug(string message) => cmdlet.Debug(message, false); + + public void Verbose(string message) => cmdlet.Verbose(message); + + public void Info(string message, string[]? tags = null) => cmdlet.Info(message, tags); + + public void Warning(string message) => cmdlet.Warning(message); +} + +public abstract class TaskCmdlet : TaskCmdlet { } + +public abstract class TaskCmdlet : BetterPSCmdlet, IDisposable + where TOutput : notnull +{ + /// Called once before the first pipeline object. Override for async initialization. + protected virtual Task Begin() => Task.CompletedTask; + + /// Called for each pipeline input object. Override to process items asynchronously. + protected virtual Task Process() => Task.CompletedTask; + + /// Called after all pipeline input has been processed. Override for async finalization. + protected virtual Task End() => Task.CompletedTask; + + /// Called when the pipeline is stopped (Ctrl+C). Override for async cleanup/resource release. + protected virtual Task Clean() => Task.CompletedTask; + + /// Synchronous hook executed on the main thread before is enqueued. Use for setup that must run on the pipeline thread (e.g. reading bound parameters). + protected virtual void PreBegin() { } + + /// Synchronous hook executed on the main thread after completes and its output is drained. + protected virtual void PostBegin() { } + + /// Synchronous hook executed on the main thread before is enqueued. + protected virtual void PreProcess() { } + + /// Synchronous hook executed on the main thread after completes and its output is drained. + protected virtual void PostProcess() { } + + /// Synchronous hook executed on the main thread before is enqueued. + protected virtual void PreEnd() { } + + /// Synchronous hook executed on the main thread after completes and its output is drained. + protected virtual void PostEnd() { } + + /// Synchronous hook executed on the main thread during before runs. + protected virtual void PreClean() { } + + /// Synchronous hook executed on the main thread during before runs. + protected virtual void PostClean() { } + + /// + /// Queues output for the cmdlet pipeline. This is the primary method used to send data through the async pipeline, + /// and the other output helpers build on top of it. + /// + private void AddOutput(object item, bool raw = false, CancellationToken? cancelToken = null) + { + if (_output is null) + { + throw new InvalidOperationException("WriteObject cannot be called before the pipeline is initialized."); + } + _output.Add(new(item, raw), cancelToken ?? PipelineStopToken); + } + + /// + /// Queues an action to be executed on the main thread of the cmdlet. This is useful for evaluating script properties, etc. that need to be run on the main thread to avoid marshalling issues. Your function can return a result and it will be brought back to the calling thread, but it must be serializable across threads (i.e. no PSObjects, etc.) + /// + public async Task Exec(Func action) + { + TaskCompletionSource response = new(TaskCreationOptions.RunContinuationsAsynchronously); + AddOutput(new MainAction(() => action(), response)); + return (T?)await response.Task.ConfigureAwait(false) ?? default!; + } + + /// + /// Queues an action to be executed on the main thread of the cmdlet. This is useful for evaluating script properties, etc. that need to be run on the main thread to avoid marshalling issues. + /// + protected async Task Post(Action action) + { + TaskCompletionSource response = new(TaskCreationOptions.RunContinuationsAsynchronously); + AddOutput(new MainAction(() => + { + action(); + return null; + }, response)); + await response.Task.ConfigureAwait(false); + } + + /// + /// Buffers output from asynchronous pipeline steps on separate threads before writing it to the pipeline. + /// A single collection is reused across all pipeline steps for the cmdlet's lifetime. + /// + private BlockingCollection _output = []; + + /// + /// Work queue feeding the single persistent background worker. Each pipeline step + /// (Begin, Process, End) enqueues its async method here rather than spawning a new Task.Run. + /// + private readonly BlockingCollection> _workQueue = []; + + /// + /// The single background worker task that processes all pipeline steps sequentially. + /// + private Task? _workerTask; + + // Override the pscmdlet entrypoints to route through the persistent worker + protected sealed override void BeginProcessing() + { + _workerTask = Task.Run(WorkerLoopAsync, PipelineStopToken); + PreBegin(); + ExecuteStep(Begin); + PostBegin(); + } + + protected sealed override void ProcessRecord() + { + PreProcess(); + ExecuteStep(Process); + PostProcess(); + } + + protected sealed override void EndProcessing() + { + PreEnd(); + ExecuteStep(End); + + // Signal no more work; the worker will complete the output collection + _workQueue.CompleteAdding(); + + // Drain any final items (e.g. if worker adds output after the sentinel race) + foreach (OutputItem item in _output.GetConsumingEnumerable(PipelineStopToken)) + { + ProcessOutput(item); + } + + // Wait for the worker to finish and clean up + _workerTask?.GetAwaiter().GetResult(); + + PostEnd(); + } + + + /// + /// Enqueues an async work item to the persistent worker and drains output on the + /// main thread until the step signals completion via a sentinel. + /// + private void ExecuteStep(Func work) + { + _workQueue.Add(work, PipelineStopToken); + + // Drain output items until the worker signals this step is done + foreach (OutputItem item in _output.GetConsumingEnumerable(PipelineStopToken)) + { + if (item.Item is StepComplete) return; + ProcessOutput(item); + } + } + + /// + /// The single persistent background worker. Processes queued work items sequentially, + /// emitting a sentinel after each one so the main thread + /// knows when to stop draining and return from the current pipeline step. + /// + private async Task WorkerLoopAsync() + { + try + { + foreach (Func work in _workQueue.GetConsumingEnumerable(PipelineStopToken)) + { + try + { + await work().ConfigureAwait(false); + } + catch (Exception ex) + { + Error(ex, terminating: true); + } + // Signal step completion so the main thread's EnqueueAndDrain returns + _output.Add(new OutputItem(new StepComplete(), false), PipelineStopToken); + } + } + finally + { + _output.CompleteAdding(); + } + } + + /// + /// Executes the Clean step in isolation. Used by StopProcessing when the main + /// worker may already be dead due to cancellation. + /// + private void ExecuteCleanStep() + { + PreClean(); + using var cleanOutput = new BlockingCollection(); + _output = cleanOutput; + + var task = Task.Run(async () => + { + try + { + await Clean().ConfigureAwait(false); + } + catch { /* best-effort cleanup */ } + finally + { + cleanOutput.CompleteAdding(); + } + }); + + try + { + foreach (OutputItem item in cleanOutput.GetConsumingEnumerable()) + ProcessOutput(item); + } + catch { /* pipeline may already be stopped */ } + + try { task.GetAwaiter().GetResult(); } catch { } + PostClean(); + } + + private void ProcessOutput(OutputItem inputObject) + { + (object? item, bool raw) = inputObject; + + if (raw) + { + base.WriteObject(item); + return; + } + + switch (item) + { + case TerminatingError terminatingError: + ThrowTerminatingError(terminatingError.Error); + break; + case ShouldProcessPrompt prompt: + bool response = string.IsNullOrEmpty(prompt.Action) + ? ShouldProcess(prompt.Target) + : ShouldProcess(prompt.Target, prompt.Action); + prompt.Response.TrySetResult(response); + break; + case ShouldProcessCustomPrompt customPrompt: + bool customResponse = ShouldProcess(customPrompt.whatIfMessage, customPrompt.confirmHeader, customPrompt.confirmMessage); + customPrompt.Response.TrySetResult(customResponse); + break; + case ErrorRecord errorRecord: + base.WriteError(errorRecord); + break; + case InformationRecord informationRecord: + base.WriteInformation(informationRecord); + break; + case TaggedInformationInfo taggedInformationInfo: + base.WriteInformation(taggedInformationInfo.MessageData, taggedInformationInfo.Tags); + break; + case WarningRecord warningRecord: + base.WriteWarning(warningRecord.Message); + break; + case VerboseRecord verboseRecord: + base.WriteVerbose(verboseRecord.Message); + break; + case DebugRecord debugRecord: + base.WriteDebug(debugRecord.Message); + break; + case ProgressRecord progressRecord: + base.WriteProgress(progressRecord); + break; + case MainAction mainAction: + try + { + object? result = mainAction.Action(); + mainAction.Response?.TrySetResult(result); + } + catch (Exception ex) + { + if (mainAction.Response == null) throw; + mainAction.Response.TrySetException(ex); + } + finally + { + // Ensure we don't have any deadlocks by waiting on the main thread for a response that will never come because of an exception, etc. + if (!mainAction.Response?.Task?.IsCompleted ?? false) + { + mainAction.Response?.TrySetCanceled(); + } + } + break; + case TOutput: + base.WriteObject(item); + break; + default: + throw new InvalidOperationException($"Unexpected output item type: {item.GetType().FullName}"); + } + } + + + /// + /// Writes an object to the pipeline via the async-safe output buffer. + /// This override routes output through the so it can + /// be called from any thread without marshalling issues. + /// + /// The object to emit to the pipeline. + /// When true, enumerates objects and writes each element individually. + public void WriteObject(TOutput[] outputObject, bool enumerateCollection = false) + { + if (enumerateCollection && outputObject is not string) + { + foreach (TOutput? item in outputObject) + { + if (item is null) continue; + AddOutput(item, true); + } + return; + } + + AddOutput(outputObject, true); + } + public void WriteObject(TOutput outputObject) => AddOutput(outputObject, true); + public new void WriteObject(object outputObject, bool enumerateCollection = false) => throw new InvalidCastException("You attempted to write an object not compatible with the cmdlet's generic output type."); + + public void Output(TOutput output) => AddOutput(output); + public void Output(IEnumerable output) => AddOutput(output, true); + public new void Debug(string message, bool raw = false) => WriteDebug(raw ? message : $"{Name}: {message}"); + public new void Verbose(string message, bool raw = false) => WriteVerbose(raw ? message : $"{Name}: {message}"); + public new void Warning(string message, bool raw = false) => WriteWarning(raw ? message : $"{Name}: {message}"); + public new void Info(string message, string[]? tags = null, bool raw = false) + => WriteInformation(raw ? message : $"{Name}: {message}", tags ?? []); + public void WriteHost(string message, bool raw = false) + => WriteInformation(raw ? message : $"{Name}: {message}", ["PSHOST"]); + public new void Progress( + string activity, + string status = "", + string currentOperation = "", + int percentComplete = 0, + int id = 1, + int parentId = -1, + bool completed = false + ) => WriteProgress(new ProgressRecord(id, activity, status) + { + CurrentOperation = currentOperation, + ParentActivityId = parentId, + RecordType = completed || percentComplete == 100 ? ProgressRecordType.Completed : ProgressRecordType.Processing, + PercentComplete = percentComplete + }); + + public new void Error( + Exception exception, + string? recommendedAction = null, + string errorId = "PSCmdletError", + object? targetObject = null, + // Usually comes from the exception message, specify this to override + string? errorDetailsMessage = null, + // This is often autodetermined + ErrorCategory? category = null, + bool terminating = false) + { + ErrorRecord error = new( + exception, + errorId, + category ?? exception switch + { + ArgumentException => ErrorCategory.InvalidArgument, + FileNotFoundException => ErrorCategory.ObjectNotFound, + InvalidOperationException => ErrorCategory.InvalidOperation, + NotSupportedException => ErrorCategory.NotSpecified, + UnauthorizedAccessException => ErrorCategory.SecurityError, + PathTooLongException => ErrorCategory.InvalidArgument, + DirectoryNotFoundException => ErrorCategory.ObjectNotFound, + IOException => ErrorCategory.WriteError, + NullReferenceException => ErrorCategory.InvalidData, + FormatException => ErrorCategory.InvalidData, + TimeoutException => ErrorCategory.OperationTimeout, + OutOfMemoryException => ErrorCategory.ResourceUnavailable, + NotImplementedException => ErrorCategory.NotImplemented, + OperationCanceledException => ErrorCategory.OperationStopped, + AccessViolationException => ErrorCategory.SecurityError, + InvalidCastException => ErrorCategory.InvalidType, + _ => ErrorCategory.NotSpecified + }, + targetObject + ) + { + ErrorDetails = new ErrorDetails(errorDetailsMessage ?? exception.Message) + { + RecommendedAction = recommendedAction + } + }; + + + if (terminating) + { + AddOutput(new TerminatingError(error)); + } + else + { + WriteError(error); + } + } + + public new void Error( + string message, + string? recommendedAction = null, + string errorId = "PSCmdletError", + object? targetObject = null, + ErrorCategory category = ErrorCategory.NotSpecified, + bool terminating = false + ) => Error( + new CmdletInvocationException(message), recommendedAction, errorId, targetObject, null, category, terminating + ); + + /// Writes a warning message to the pipeline via the async-safe output buffer. + public new void WriteWarning(string message) => AddOutput(new WarningRecord(message)); + + /// Writes a verbose message to the pipeline via the async-safe output buffer. + public new void WriteVerbose(string message) => AddOutput(new VerboseRecord(message)); + + /// Writes a debug message to the pipeline via the async-safe output buffer. + public new void WriteDebug(string message) => AddOutput(new DebugRecord(message)); + + /// Writes a non-terminating error to the pipeline via the async-safe output buffer. + public new void WriteError(ErrorRecord errorRecord) => AddOutput(errorRecord); + + /// Writes a progress record to the pipeline via the async-safe output buffer. + public new void WriteProgress(ProgressRecord progressRecord) => AddOutput(progressRecord); + + /// Writes an information record to the pipeline via the async-safe output buffer. + public new void WriteInformation(InformationRecord informationRecord) => AddOutput(informationRecord); + + /// Writes tagged information data to the pipeline via the async-safe output buffer. + public new void WriteInformation(object messageData, string[] tags) + => AddOutput(new TaggedInformationInfo(messageData, tags)); + + /// + /// Async-safe equivalent of . + /// Marshals the confirmation prompt to the main thread and awaits the user's response. + /// + /// The resource being acted upon (shown in -WhatIf output). + /// The action being performed. If empty, uses a default message. + /// true if the operation should proceed; false if the user declined. + public async Task Confirm(string target, string action = "") + { + TaskCompletionSource response = new(TaskCreationOptions.RunContinuationsAsynchronously); + await using CancellationTokenRegistration _ = PipelineStopToken.Register(() => response.TrySetCanceled()); + AddOutput(new ShouldProcessPrompt(target, action, response)); + return await response.Task.ConfigureAwait(false); + } + + /// + /// Async-safe ShouldProcess with full control over the confirmation dialog text. + /// Marshals the prompt to the main thread and awaits the user's response. + /// + /// Message displayed when -WhatIf is specified. + /// Header displayed in the confirmation dialog. + /// Body text displayed in the confirmation dialog. + /// true if the operation should proceed; false if the user declined. + public async Task ConfirmCustom(string whatIfMessage, string confirmHeader = "", string confirmMessage = "") + { + TaskCompletionSource response = new(TaskCreationOptions.RunContinuationsAsynchronously); + await using CancellationTokenRegistration _ = PipelineStopToken.Register(() => response.TrySetCanceled()); + AddOutput(new ShouldProcessCustomPrompt(whatIfMessage, confirmHeader, confirmMessage, response)); + return await response.Task.ConfigureAwait(false); + } + + + // PowerShell 7.6 introduces a builtin PipelineStopToken, for older versions we + // implement our own cancellation stop trigger and its cleanup. Once 7.6 is the + // baseline we can remove the else block. + private bool _disposed; + + /// Releases resources used by the cmdlet, including the background worker and output collection. + public void Dispose() + { + if (_disposed) return; +#if !NET10_0_OR_GREATER + _cancelSource.Dispose(); +#endif + _workQueue.Dispose(); + _output.Dispose(); + _disposed = true; + GC.SuppressFinalize(this); + } + +#if !NET10_0_OR_GREATER + /// Polyfill of the PipelineStopToken from earlier versions + + private readonly CancellationTokenSource _cancelSource = new(); + + public CancellationToken PipelineStopToken => _cancelSource.Token; + + protected override void StopProcessing() + { + _cancelSource.Cancel(); + ExecuteCleanStep(); + } +#else + protected sealed override void StopProcessing() => ExecuteCleanStep(); +#endif +} + +/// +/// Lightweight base class extending with convenient helper methods +/// for writing output, errors, verbose/debug/warning messages, and progress. +/// Used as the base for both synchronous cmdlets and . +/// +public class BetterPSCmdlet : PSCmdlet +{ + /// The cmdlet's invocation name, used as a prefix in diagnostic messages. + protected string Name => MyInvocation.MyCommand.Name; + + internal void Debug(string message, bool raw = false) => WriteDebug(raw ? message : $"{Name}: {message}"); + internal void Verbose(string message, bool raw = false) => WriteVerbose(raw ? message : $"{Name}: {message}"); + internal void Warning(string message, bool raw = false) => WriteWarning(raw ? message : $"{Name}: {message}"); + + + internal void Info(string message, string[]? tags = null, bool raw = false) + => WriteInformation(raw ? message : $"{Name}: {message}", tags ?? []); + internal void Console(string message, bool raw = false) + => WriteInformation(raw ? message : $"{Name}: {message}", ["PSHOST"]); + internal void Progress( + string activity, + string status = "", + string currentOperation = "", + int percentComplete = 0, + int id = 1, + int parentId = -1, + bool completed = false + ) => WriteProgress(new ProgressRecord(id, activity, status) + { + CurrentOperation = currentOperation, + ParentActivityId = parentId, + RecordType = completed || percentComplete == 100 ? ProgressRecordType.Completed : ProgressRecordType.Processing, + PercentComplete = percentComplete + }); + + internal void Error( + Exception exception, + string? recommendedAction = null, + string errorId = "PSCmdletError", + object? targetObject = null, + // Usually comes from the exception message, specify this to override + string? errorDetailsMessage = null, + // This is often autodetermined + ErrorCategory? category = null, + bool terminating = false) + { + ErrorRecord error = new( + exception, + errorId, + category ?? exception switch + { + ArgumentException => ErrorCategory.InvalidArgument, + FileNotFoundException => ErrorCategory.ObjectNotFound, + InvalidOperationException => ErrorCategory.InvalidOperation, + NotSupportedException => ErrorCategory.NotSpecified, + UnauthorizedAccessException => ErrorCategory.SecurityError, + PathTooLongException => ErrorCategory.InvalidArgument, + DirectoryNotFoundException => ErrorCategory.ObjectNotFound, + IOException => ErrorCategory.WriteError, + NullReferenceException => ErrorCategory.InvalidData, + FormatException => ErrorCategory.InvalidData, + TimeoutException => ErrorCategory.OperationTimeout, + OutOfMemoryException => ErrorCategory.ResourceUnavailable, + NotImplementedException => ErrorCategory.NotImplemented, + OperationCanceledException => ErrorCategory.OperationStopped, + AccessViolationException => ErrorCategory.SecurityError, + InvalidCastException => ErrorCategory.InvalidType, + _ => ErrorCategory.NotSpecified + }, + targetObject + ) + { + ErrorDetails = new ErrorDetails(errorDetailsMessage ?? exception.Message) + { + RecommendedAction = recommendedAction + } + }; + + if (terminating) + { + ThrowTerminatingError(error); + } + else + { + try + { + WriteError(error); + } + catch (PipelineStoppedException) + { + // This can happen if the pipeline is already stopping when we try to write the error, in that case we just swallow it since the error is likely still visible in the console and there's nothing we can do about it. + } + } + } + + internal void Error( + string message, + string? recommendedAction = null, + string errorId = "PSCmdletError", + object? targetObject = null, + ErrorCategory category = ErrorCategory.NotSpecified, + bool terminating = false + ) => Error( + new CmdletInvocationException(message), recommendedAction, errorId, targetObject, null, category, terminating + ); +} + +internal record OutputItem(object Item, bool Raw); +internal record TaggedInformationInfo(object MessageData, string[] Tags); +internal record ShouldProcessPrompt(string Target, string Action, TaskCompletionSource Response); +internal record ShouldProcessCustomPrompt( + string whatIfMessage, + string confirmHeader, + string confirmMessage, + TaskCompletionSource Response +); +internal record TerminatingError(ErrorRecord Error); +/** Send this via the pipeline to execute an action on the main thread **/ +internal record MainAction(Func Action, TaskCompletionSource? Response = null); +/** Sentinel item placed in the output collection to signal that a pipeline step has completed **/ +internal record StepComplete; \ No newline at end of file diff --git a/Source/PowerShell/Commands/ClearCache.cs b/Source/PowerShell/Commands/ClearCache.cs new file mode 100644 index 0000000..41b15d4 --- /dev/null +++ b/Source/PowerShell/Commands/ClearCache.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/PowerShell/Commands/Install.cs b/Source/PowerShell/Commands/Install.cs new file mode 100644 index 0000000..3337a97 --- /dev/null +++ b/Source/PowerShell/Commands/Install.cs @@ -0,0 +1,391 @@ +using System.Linq; +using System.Management.Automation; +using System.Text.Json; + +using static System.IO.Path; + +namespace ModuleFast.Commands; + +[Cmdlet(VerbsLifecycle.Install, "ModuleFast", + DefaultParameterSetName = "Specification", + SupportsShouldProcess = true, + ConfirmImpact = ConfirmImpact.Low +)] +[OutputType(typeof(ModuleFastInfo))] +public class InstallModuleFastCommand : TaskCmdlet +{ + private const int PlanProgressId = 1; + private const int InstallProgressId = 2; + + [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; } = "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 = []; + private readonly List _installPlan = []; + private CancellationTokenSource? _timeoutSource; + private bool _destinationExplicitlySpecified; + + private CmdletInteraction cmdletInteractor => new TaskCmdletInteractor(this); + + protected override async Task Begin() + { + _destinationExplicitlySpecified = MyInvocation.BoundParameters.ContainsKey(nameof(Destination)); + + // Resolve CILockFilePath relative to PowerShell's current location + if (!IsPathRooted(CILockFilePath)) + { + CILockFilePath = GetFullPath(Combine( + SessionState.Path.CurrentFileSystemLocation.Path, + CILockFilePath + )); + } + + if (Update) ModuleFastCache.Instance.Clear(); + + // Normalize source + if (Uri.TryCreate(Source, UriKind.Absolute, out Uri? srcUri) && + srcUri.Scheme is not "http" and not "https") + { + Source = $"https://{Source}/index.json"; + } + + string defaultRepoPath = Combine( + Environment.GetFolderPath( + Environment.SpecialFolder.LocalApplicationData), + "powershell", + "Modules" + ); + + if (string.IsNullOrEmpty(Destination)) + { + // Map scope to destination + if (Scope == InstallScope.CurrentUser) + { + // Use legacy documents path + string docsPath = Combine( + Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), + "PowerShell", "Modules"); + Destination = docsPath; + } + else + { + Destination = PathHelper.GetPSDefaultModulePath(allUsers: Scope == InstallScope.AllUsers); + + if (OperatingSystem.IsWindows() && Scope != InstallScope.CurrentUser) + { + string defaultWindowsPath = Combine( + Environment.GetFolderPath( + Environment.SpecialFolder.MyDocuments), + "PowerShell", + "Modules" + ); + if (string.Equals(Destination, defaultWindowsPath, StringComparison.OrdinalIgnoreCase)) + { + Debug($"Windows Documents module folder detected. Changing to {defaultRepoPath}"); + Destination = defaultRepoPath; + } + } + } + } + else + { + // User explicitly specified a non-standard destination; don't touch the profile. + NoProfileUpdate = true; + } + + if (string.IsNullOrEmpty(Destination)) + ThrowTerminatingError(new ErrorRecord( + new InvalidOperationException("Failed to determine destination path."), + "DestinationNotFound", ErrorCategory.InvalidOperation, null)); + + // Resolve relative Destination against PowerShell's current location + if (!IsPathRooted(Destination)) + { + Destination = GetFullPath(Combine( + SessionState.Path.CurrentFileSystemLocation.Path, + Destination + )); + } + + if (!Directory.Exists(Destination)) + { + if ( + string.Equals(Destination, defaultRepoPath, StringComparison.OrdinalIgnoreCase) + && await Confirm(Destination, "Create default repository folder").ConfigureAwait(false) + ) + { + Directory.CreateDirectory(Destination!); + } + } + + if (!NoPSModulePathUpdate) + { + string[] modulePaths = (Environment.GetEnvironmentVariable("PSModulePath") ?? "") + .Split(PathSeparator, StringSplitOptions.RemoveEmptyEntries); + if (!modulePaths.Contains(Destination, StringComparer.OrdinalIgnoreCase)) + { + await PathHelper.AddDestinationToPSModulePath(Destination, NoProfileUpdate, cmdletInteractor).ConfigureAwait(false); + } + } + + _timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(PipelineStopToken); + _timeoutSource.CancelAfter(TimeSpan.FromSeconds(Timeout * 10)); // overall timeout + } + + protected override async Task Process() + { + switch (ParameterSetName) + { + case "Specification": + foreach (ModuleFastSpec spec in Specification ?? []) + { + if (!_modulesToInstall.Add(spec)) + Warning($"{spec} was specified twice, skipping duplicate."); + } + break; + + case "ModuleFastInfo": + foreach (ModuleFastInfo 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 (string p in paths) + { + ModuleFastSpec[] specs = SpecFileReader.ConvertFromRequiredSpec(p, SpecFileType, cmdletInteractor); + foreach (ModuleFastSpec spec in specs) + _modulesToInstall.Add(spec); + } + break; + } + } + + protected override async Task End() + { + try + { + CancellationToken ct = _timeoutSource?.Token ?? PipelineStopToken; + + ModuleFastInfo[] finalInstallPlan; + + if (_installPlan.Count > 0) + { + finalInstallPlan = _installPlan.ToArray(); + } + else + { + // Auto-detect spec files if nothing was specified + if (_modulesToInstall.Count == 0 && ParameterSetName == "Specification") + { + Verbose("๐Ÿ”Ž No modules specified. Beginning SpecFile detection..."); + + if (CI && File.Exists(CILockFilePath)) + { + Debug($"Found lockfile at {CILockFilePath}. Using for specification evaluation."); + ModuleFastSpec[] lockSpecs = SpecFileReader.ConvertFromRequiredSpec(CILockFilePath, SpecFileType.AutoDetect, cmdletInteractor); + foreach (ModuleFastSpec spec in lockSpecs) + _modulesToInstall.Add(spec); + if (Update) + { + Verbose("-Update specified but lockfile found. Ignoring -Update."); + Update = false; + } + } + else + { + IEnumerable specFiles = SpecFileReader.FindRequiredSpecFiles(SessionState.Path.CurrentFileSystemLocation.Path); + if (specFiles == null || !specFiles.Any()) + { + Warning($"No specfiles found in {SessionState.Path.CurrentFileSystemLocation}."); + } + else + { + foreach (string specFile in specFiles) + { + Verbose($"Found Specfile {specFile}. Evaluating..."); + ModuleFastSpec[] fileSpecs = SpecFileReader.ConvertFromRequiredSpec(specFile, SpecFileType, cmdletInteractor); + foreach (ModuleFastSpec 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)); + + Progress("Install-ModuleFast", "Plan", percentComplete: 1, id: PlanProgressId); + + string[] modulePaths; + if (DestinationOnly) + { + modulePaths = [Destination!]; + } + else if (_destinationExplicitlySpecified) + { + IEnumerable allModulePaths = (Environment.GetEnvironmentVariable("PSModulePath") + ?.Split(PathSeparator, StringSplitOptions.RemoveEmptyEntries) ?? []) + .Prepend(Destination!); + modulePaths = allModulePaths + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + else + { + modulePaths = Environment.GetEnvironmentVariable("PSModulePath") + ?.Split(PathSeparator, StringSplitOptions.RemoveEmptyEntries) ?? []; + } + + var planner = new ModuleFastPlanner(Source); + HashSet nonStreamingPlanSet = await planner.GetPlan( + _modulesToInstall, modulePaths, Update, Prerelease, StrictSemVer, DestinationOnly, ct, cmdlet: cmdletInteractor).ConfigureAwait(false); + finalInstallPlan = nonStreamingPlanSet.OrderBy(static module => module.Name, StringComparer.OrdinalIgnoreCase).ToArray(); + Progress("Install-ModuleFast", "Plan complete", percentComplete: 100, id: PlanProgressId); + } + + if (finalInstallPlan.Length == 0) + { + string msg = $"โœ… {_modulesToInstall.Count} Module Specifications have all been satisfied by installed modules. If you would like to check for newer versions remotely, specify -Update"; + Verbose(msg); + return; + } + + if (Plan || !await Confirm(Destination!, $"Install {finalInstallPlan.Length} Modules").ConfigureAwait(false)) + { + if (Plan) + Verbose($"๐Ÿ“‘ -Plan was specified. Returning a plan including {finalInstallPlan.Length} Module Specifications"); + foreach (ModuleFastInfo info in finalInstallPlan) + WriteObject(info); + } + else + { + int total = finalInstallPlan.Length; + int completed = 0; + Progress("Install-ModuleFast", $"Installing 0/{total} Modules", percentComplete: 0, id: InstallProgressId); + + // The callback is invoked synchronously on the completing thread pool thread. + // WriteProgress is thread-safe in PowerShell's runtime infrastructure. + var updateInstallProgress = new Action(_ => + { + int done = Interlocked.Increment(ref completed); + int pct = done * 100 / total; + Progress("Install-ModuleFast", $"Installing {done}/{total} Modules", percentComplete: pct, id: InstallProgressId); + }); + + var installer = new ModuleFastInstaller(Source); + List installedModules = await installer.InstallModules( + finalInstallPlan, + Destination!, + Update || ParameterSetName == "ModuleFastInfo", + ct, + cmdletInteractor, + ThrottleLimit, + updateInstallProgress + ).ConfigureAwait(false); + + Verbose("โœ… All required modules installed! Exiting."); + + if (PassThru) + foreach (ModuleFastInfo m in installedModules) + WriteObject(m); + + if (CI) + { + Verbose($"Writing lockfile to {CILockFilePath}"); + var lockFile = new Dictionary(); + foreach (ModuleFastInfo m in finalInstallPlan) + lockFile[m.Name] = m.ModuleVersion.ToString(); + + string 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 + { + // Ensure progress records are always completed + Progress("Install-ModuleFast", "Plan done", percentComplete: 100, id: PlanProgressId, completed: true); + Progress("Install-ModuleFast", "Install done", percentComplete: 100, id: InstallProgressId, completed: true); + _timeoutSource?.Dispose(); + } + } +} \ No newline at end of file diff --git a/Source/PowerShell/PowerShell.csproj b/Source/PowerShell/PowerShell.csproj new file mode 100644 index 0000000..f0d8bb4 --- /dev/null +++ b/Source/PowerShell/PowerShell.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + ModuleFast + ModuleFast + true + false + false + true + + + + + + + + diff --git a/build.ps1 b/build.ps1 index 38da172..fbdfe39 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,10 +1,19 @@ $ErrorActionPreference = 'Stop' -#Use the local copy rather than the bootstrap to speed things up -. $PSScriptRoot/ModuleFast.ps1 -ImportNugetVersioning -$module = Import-Module $PSScriptRoot/ModuleFast.psd1 -Force -PassThru -Install-ModuleFast -Remove-Module $module +#Run in a separate job to avoid keeping the legacy nuget versioning assembly in memory. +Start-Job { + #Use the simple v1 for now for bootstrapping + $v0ModuleFastUri = 'https://github.com/JustinGrote/ModuleFast/releases/download/v0.6.1/ModuleFast.ps1' + iwr $v0ModuleFastUri | iex + Install-ModuleFast -NoPSModulePathUpdate +} | Receive-Job -Wait -AutoRemoveJob + +# HACK: There's a problem in CI with the modulepath on windows, will fix this later +if ($isWindows) { + $mfPath = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'powershell/Modules' + $env:PSModulePath = "$mfPath;$env:PSModulePath" +} + Push-Location $PSScriptRoot try { Invoke-Build @args diff --git a/global.json b/global.json new file mode 100644 index 0000000..26e4a86 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + } +}