diff --git a/.github/workflows/BuildOP.yml b/.github/workflows/BuildOP.yml new file mode 100644 index 0000000..1a780f1 --- /dev/null +++ b/.github/workflows/BuildOP.yml @@ -0,0 +1,512 @@ + +name: Build OP Module +on: + push: + pull_request: + workflow_dispatch: +jobs: + TestPowerShellOnLinux: + runs-on: ubuntu-latest + steps: + - name: InstallPester + id: InstallPester + shell: pwsh + run: | + $Parameters = @{} + $Parameters.PesterMaxVersion = ${env:PesterMaxVersion} + foreach ($k in @($parameters.Keys)) { + if ([String]::IsNullOrEmpty($parameters[$k])) { + $parameters.Remove($k) + } + } + Write-Host "::debug:: InstallPester $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" + & {<# + .Synopsis + Installs Pester + .Description + Installs Pester + #> + param( + # The maximum pester version. Defaults to 4.99.99. + [string] + $PesterMaxVersion = '4.99.99' + ) + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Install-Module -Name Pester -Repository PSGallery -Force -Scope CurrentUser -MaximumVersion $PesterMaxVersion -SkipPublisherCheck -AllowClobber + Import-Module Pester -Force -PassThru -MaximumVersion $PesterMaxVersion} @Parameters + - name: Check out repository + uses: actions/checkout@v2 + - name: RunPester + id: RunPester + shell: pwsh + run: | + $Parameters = @{} + $Parameters.ModulePath = ${env:ModulePath} + $Parameters.PesterMaxVersion = ${env:PesterMaxVersion} + $Parameters.NoCoverage = ${env:NoCoverage} + $Parameters.NoCoverage = $parameters.NoCoverage -match 'true'; + foreach ($k in @($parameters.Keys)) { + if ([String]::IsNullOrEmpty($parameters[$k])) { + $parameters.Remove($k) + } + } + Write-Host "::debug:: RunPester $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" + & {<# + .Synopsis + Runs Pester + .Description + Runs Pester tests after importing a PowerShell module + #> + param( + # The module path. If not provided, will default to the second half of the repository ID. + [string] + $ModulePath, + # The Pester max version. By default, this is pinned to 4.99.99. + [string] + $PesterMaxVersion = '4.99.99', + + # If set, will not collect code coverage. + [switch] + $NoCoverage + ) + + $global:ErrorActionPreference = 'continue' + $global:ProgressPreference = 'silentlycontinue' + + $orgName, $moduleName = $env:GITHUB_REPOSITORY -split "/" + if (-not $ModulePath) { $ModulePath = ".\$moduleName.psd1" } + $importedPester = Import-Module Pester -Force -PassThru -MaximumVersion $PesterMaxVersion + $importedModule = Import-Module $ModulePath -Force -PassThru + $importedPester, $importedModule | Out-Host + + $codeCoverageParameters = @{ + CodeCoverage = "$($importedModule | Split-Path)\*-*.ps1" + CodeCoverageOutputFile = ".\$moduleName.Coverage.xml" + } + + if ($NoCoverage) { + $codeCoverageParameters = @{} + } + + + $result = + Invoke-Pester -PassThru -Verbose -OutputFile ".\$moduleName.TestResults.xml" -OutputFormat NUnitXml @codeCoverageParameters + + "::set-output name=TotalCount::$($result.TotalCount)", + "::set-output name=PassedCount::$($result.PassedCount)", + "::set-output name=FailedCount::$($result.FailedCount)" | Out-Host + if ($result.FailedCount -gt 0) { + "::debug:: $($result.FailedCount) tests failed" + foreach ($r in $result.TestResult) { + if (-not $r.Passed) { + "::error::$($r.describe, $r.context, $r.name -join ' ') $($r.FailureMessage)" + } + } + throw "::error:: $($result.FailedCount) tests failed" + } + } @Parameters + - name: PublishTestResults + uses: actions/upload-artifact@main + with: + name: PesterResults + path: '**.TestResults.xml' + if: ${{always()}} + TagReleaseAndPublish: + runs-on: ubuntu-latest + if: ${{ success() }} + steps: + - name: Check out repository + uses: actions/checkout@v2 + - name: TagModuleVersion + id: TagModuleVersion + shell: pwsh + run: | + $Parameters = @{} + $Parameters.ModulePath = ${env:ModulePath} + $Parameters.UserEmail = ${env:UserEmail} + $Parameters.UserName = ${env:UserName} + $Parameters.TagVersionFormat = ${env:TagVersionFormat} + $Parameters.TagAnnotationFormat = ${env:TagAnnotationFormat} + foreach ($k in @($parameters.Keys)) { + if ([String]::IsNullOrEmpty($parameters[$k])) { + $parameters.Remove($k) + } + } + Write-Host "::debug:: TagModuleVersion $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" + & {param( + [string] + $ModulePath, + + # The user email associated with a git commit. + [string] + $UserEmail, + + # The user name associated with a git commit. + [string] + $UserName, + + # The tag version format (default value: 'v$(imported.Version)') + # This can expand variables. $imported will contain the imported module. + [string] + $TagVersionFormat = 'v$($imported.Version)', + + # The tag version format (default value: '$($imported.Name) $(imported.Version)') + # This can expand variables. $imported will contain the imported module. + [string] + $TagAnnotationFormat = '$($imported.Name) $($imported.Version)' + ) + + + $gitHubEvent = if ($env:GITHUB_EVENT_PATH) { + [IO.File]::ReadAllText($env:GITHUB_EVENT_PATH) | ConvertFrom-Json + } else { $null } + + + @" + ::group::GitHubEvent + $($gitHubEvent | ConvertTo-Json -Depth 100) + ::endgroup:: + "@ | Out-Host + + if (-not ($gitHubEvent.head_commit.message -match "Merge Pull Request #(?\d+)") -and + (-not $gitHubEvent.psobject.properties['inputs'])) { + "::warning::Pull Request has not merged, skipping Tagging" | Out-Host + return + } + + + + $imported = + if (-not $ModulePath) { + $orgName, $moduleName = $env:GITHUB_REPOSITORY -split "/" + Import-Module ".\$moduleName.psd1" -Force -PassThru -Global + } else { + Import-Module $modulePath -Force -PassThru -Global + } + + if (-not $imported) { return } + + $targetVersion =$ExecutionContext.InvokeCommand.ExpandString($TagVersionFormat) + $existingTags = git tag --list + + @" + Target Version: $targetVersion + + Existing Tags: + $($existingTags -join [Environment]::NewLine) + "@ | Out-Host + + $versionTagExists = $existingTags | Where-Object { $_ -match $targetVersion } + + if ($versionTagExists) { + "::warning::Version $($versionTagExists)" + return + } + + if (-not $UserName) { $UserName = $env:GITHUB_ACTOR } + if (-not $UserEmail) { $UserEmail = "$UserName@github.com" } + git config --global user.email $UserEmail + git config --global user.name $UserName + + git tag -a $targetVersion -m $ExecutionContext.InvokeCommand.ExpandString($TagAnnotationFormat) + git push origin --tags + + if ($env:GITHUB_ACTOR) { + exit 0 + }} @Parameters + - name: ReleaseModule + id: ReleaseModule + shell: pwsh + run: | + $Parameters = @{} + $Parameters.ModulePath = ${env:ModulePath} + $Parameters.UserEmail = ${env:UserEmail} + $Parameters.UserName = ${env:UserName} + $Parameters.TagVersionFormat = ${env:TagVersionFormat} + $Parameters.ReleaseNameFormat = ${env:ReleaseNameFormat} + $Parameters.ReleaseAsset = ${env:ReleaseAsset} + $Parameters.ReleaseAsset = $parameters.ReleaseAsset -split ';' -replace '^[''"]' -replace '[''"]$' + foreach ($k in @($parameters.Keys)) { + if ([String]::IsNullOrEmpty($parameters[$k])) { + $parameters.Remove($k) + } + } + Write-Host "::debug:: ReleaseModule $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" + & {param( + [string] + $ModulePath, + + # The user email associated with a git commit. + [string] + $UserEmail, + + # The user name associated with a git commit. + [string] + $UserName, + + # The tag version format (default value: 'v$(imported.Version)') + # This can expand variables. $imported will contain the imported module. + [string] + $TagVersionFormat = 'v$($imported.Version)', + + # The release name format (default value: '$($imported.Name) $($imported.Version)') + [string] + $ReleaseNameFormat = '$($imported.Name) $($imported.Version)', + + # Any assets to attach to the release. Can be a wildcard or file name. + [string[]] + $ReleaseAsset + ) + + + $gitHubEvent = if ($env:GITHUB_EVENT_PATH) { + [IO.File]::ReadAllText($env:GITHUB_EVENT_PATH) | ConvertFrom-Json + } else { $null } + + + @" + ::group::GitHubEvent + $($gitHubEvent | ConvertTo-Json -Depth 100) + ::endgroup:: + "@ | Out-Host + + if (-not ($gitHubEvent.head_commit.message -match "Merge Pull Request #(?\d+)") -and + (-not $gitHubEvent.psobject.properties['inputs'])) { + "::warning::Pull Request has not merged, skipping GitHub release" | Out-Host + return + } + + + + $imported = + if (-not $ModulePath) { + $orgName, $moduleName = $env:GITHUB_REPOSITORY -split "/" + Import-Module ".\$moduleName.psd1" -Force -PassThru -Global + } else { + Import-Module $modulePath -Force -PassThru -Global + } + + if (-not $imported) { return } + + $targetVersion =$ExecutionContext.InvokeCommand.ExpandString($TagVersionFormat) + $targetReleaseName = $targetVersion + $releasesURL = 'https://api.github.com/repos/${{github.repository}}/releases' + "Release URL: $releasesURL" | Out-Host + $listOfReleases = Invoke-RestMethod -Uri $releasesURL -Method Get -Headers @{ + "Accept" = "application/vnd.github.v3+json" + "Authorization" = 'Bearer ${{ secrets.GITHUB_TOKEN }}' + } + + $releaseExists = $listOfReleases | Where-Object tag_name -eq $targetVersion + + if ($releaseExists) { + "::warning::Release '$($releaseExists.Name )' Already Exists" | Out-Host + $releasedIt = $releaseExists + } else { + $releasedIt = Invoke-RestMethod -Uri $releasesURL -Method Post -Body ( + [Ordered]@{ + owner = '${{github.owner}}' + repo = '${{github.repository}}' + tag_name = $targetVersion + name = $ExecutionContext.InvokeCommand.ExpandString($ReleaseNameFormat) + body = + if ($env:RELEASENOTES) { + $env:RELEASENOTES + } elseif ($imported.PrivateData.PSData.ReleaseNotes) { + $imported.PrivateData.PSData.ReleaseNotes + } else { + "$($imported.Name) $targetVersion" + } + draft = if ($env:RELEASEISDRAFT) { [bool]::Parse($env:RELEASEISDRAFT) } else { $false } + prerelease = if ($env:PRERELEASE) { [bool]::Parse($env:PRERELEASE) } else { $false } + } | ConvertTo-Json + ) -Headers @{ + "Accept" = "application/vnd.github.v3+json" + "Content-type" = "application/json" + "Authorization" = 'Bearer ${{ secrets.GITHUB_TOKEN }}' + } + } + + + + + + if (-not $releasedIt) { + throw "Release failed" + } else { + $releasedIt | Out-Host + } + + $releaseUploadUrl = $releasedIt.upload_url -replace '\{.+$' + + if ($ReleaseAsset) { + $fileList = Get-ChildItem -Recurse + $filesToRelease = + @(:nextFile foreach ($file in $fileList) { + foreach ($relAsset in $ReleaseAsset) { + if ($relAsset -match '[\*\?]') { + if ($file.Name -like $relAsset) { + $file; continue nextFile + } + } elseif ($file.Name -eq $relAsset -or $file.FullName -eq $relAsset) { + $file; continue nextFile + } + } + }) + + $releasedFiles = @{} + foreach ($file in $filesToRelease) { + if ($releasedFiles[$file.Name]) { + Write-Warning "Already attached file $($file.Name)" + continue + } else { + $fileBytes = [IO.File]::ReadAllBytes($file.FullName) + $releasedFiles[$file.Name] = + Invoke-RestMethod -Uri "${releaseUploadUrl}?name=$($file.Name)" -Headers @{ + "Accept" = "application/vnd.github+json" + "Authorization" = 'Bearer ${{ secrets.GITHUB_TOKEN }}' + } -Body $fileBytes -ContentType Application/octet-stream + $releasedFiles[$file.Name] + } + } + + "Attached $($releasedFiles.Count) file(s) to release" | Out-Host + } + + + + } @Parameters + - name: PublishPowerShellGallery + id: PublishPowerShellGallery + shell: pwsh + run: | + $Parameters = @{} + $Parameters.ModulePath = ${env:ModulePath} + $Parameters.Exclude = ${env:Exclude} + $Parameters.Exclude = $parameters.Exclude -split ';' -replace '^[''"]' -replace '[''"]$' + foreach ($k in @($parameters.Keys)) { + if ([String]::IsNullOrEmpty($parameters[$k])) { + $parameters.Remove($k) + } + } + Write-Host "::debug:: PublishPowerShellGallery $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" + & {param( + [string] + $ModulePath, + + [string[]] + $Exclude = @('*.png', '*.mp4', '*.jpg','*.jpeg', '*.gif', 'docs[/\]*') + ) + + $gitHubEvent = if ($env:GITHUB_EVENT_PATH) { + [IO.File]::ReadAllText($env:GITHUB_EVENT_PATH) | ConvertFrom-Json + } else { $null } + + if (-not $Exclude) { + $Exclude = @('*.png', '*.mp4', '*.jpg','*.jpeg', '*.gif','docs[/\]*') + } + + + @" + ::group::GitHubEvent + $($gitHubEvent | ConvertTo-Json -Depth 100) + ::endgroup:: + "@ | Out-Host + + @" + ::group::PSBoundParameters + $($PSBoundParameters | ConvertTo-Json -Depth 100) + ::endgroup:: + "@ | Out-Host + + if (-not ($gitHubEvent.head_commit.message -match "Merge Pull Request #(?\d+)") -and + (-not $gitHubEvent.psobject.properties['inputs'])) { + "::warning::Pull Request has not merged, skipping Gallery Publish" | Out-Host + return + } + + + $imported = + if (-not $ModulePath) { + $orgName, $moduleName = $env:GITHUB_REPOSITORY -split "/" + Import-Module ".\$moduleName.psd1" -Force -PassThru -Global + } else { + Import-Module $modulePath -Force -PassThru -Global + } + + if (-not $imported) { return } + + $foundModule = try { Find-Module -Name $imported.Name -ErrorAction SilentlyContinue} catch {} + + if ($foundModule -and (([Version]$foundModule.Version) -ge ([Version]$imported.Version))) { + "::warning::Gallery Version of $moduleName is more recent ($($foundModule.Version) >= $($imported.Version))" | Out-Host + } else { + + $gk = '${{secrets.GALLERYKEY}}' + + $rn = Get-Random + $moduleTempFolder = Join-Path $pwd "$rn" + $moduleTempPath = Join-Path $moduleTempFolder $moduleName + New-Item -ItemType Directory -Path $moduleTempPath -Force | Out-Host + + Write-Host "Staging Directory: $ModuleTempPath" + + $imported | Split-Path | + Get-ChildItem -Force | + Where-Object Name -NE $rn | + Copy-Item -Destination $moduleTempPath -Recurse + + $moduleGitPath = Join-Path $moduleTempPath '.git' + Write-Host "Removing .git directory" + if (Test-Path $moduleGitPath) { + Remove-Item -Recurse -Force $moduleGitPath + } + + if ($Exclude) { + "::notice::Attempting to Exlcude $exclude" | Out-Host + Get-ChildItem $moduleTempPath -Recurse | + Where-Object { + foreach ($ex in $exclude) { + if ($_.FullName -like $ex) { + "::notice::Excluding $($_.FullName)" | Out-Host + return $true + } + } + } | + Remove-Item + } + + Write-Host "Module Files:" + Get-ChildItem $moduleTempPath -Recurse + Write-Host "Publishing $moduleName [$($imported.Version)] to Gallery" + Publish-Module -Path $moduleTempPath -NuGetApiKey $gk + if ($?) { + Write-Host "Published to Gallery" + } else { + Write-Host "Gallery Publish Failed" + exit 1 + } + } + } @Parameters + BuildOP: + runs-on: ubuntu-latest + if: ${{ success() }} + steps: + - name: Check out repository + uses: actions/checkout@main + - name: UseEZOut + uses: StartAutomating/EZOut@master + - name: Run Action (on branch) + if: ${{github.ref_name != 'main'}} + uses: ./ + id: OPAction + - uses: actions/upload-artifact@main + id: artifact-upload-step + with: + name: op-test + path: | + OP.zip + _site +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} diff --git a/Assets/OP-Animated-Blue-Text.svg b/Assets/OP-Animated-Blue-Text.svg new file mode 100644 index 0000000..689d0b1 --- /dev/null +++ b/Assets/OP-Animated-Blue-Text.svg @@ -0,0 +1,14 @@ + + + OP-Animated-Blue-Text + + + + + + + OP +OP + + + \ No newline at end of file diff --git a/Assets/OP-Animated-Blue.svg b/Assets/OP-Animated-Blue.svg new file mode 100644 index 0000000..005432d --- /dev/null +++ b/Assets/OP-Animated-Blue.svg @@ -0,0 +1,6 @@ + + + OP-Animated-Blue + + + \ No newline at end of file diff --git a/Assets/OP-Animated-Gradient-Text.svg b/Assets/OP-Animated-Gradient-Text.svg new file mode 100644 index 0000000..b5fce9c --- /dev/null +++ b/Assets/OP-Animated-Gradient-Text.svg @@ -0,0 +1,21 @@ + + + + + + + + + + OP-Animated-Gradient-Text + + + + + + + OP +OP + + + \ No newline at end of file diff --git a/Assets/OP-Animated-Gradient.svg b/Assets/OP-Animated-Gradient.svg new file mode 100644 index 0000000..8613730 --- /dev/null +++ b/Assets/OP-Animated-Gradient.svg @@ -0,0 +1,13 @@ + + + + + + + + + + OP-Animated-Gradient + + + \ No newline at end of file diff --git a/Assets/OP-Animated-Text.svg b/Assets/OP-Animated-Text.svg new file mode 100644 index 0000000..0962247 --- /dev/null +++ b/Assets/OP-Animated-Text.svg @@ -0,0 +1,14 @@ + + + OP-Animated-Text + + + + + + + OP +OP + + + \ No newline at end of file diff --git a/Assets/OP-Animated.svg b/Assets/OP-Animated.svg new file mode 100644 index 0000000..753377e --- /dev/null +++ b/Assets/OP-Animated.svg @@ -0,0 +1,6 @@ + + + OP-Animated + + + \ No newline at end of file diff --git a/Assets/OP-Blue-Text.png b/Assets/OP-Blue-Text.png new file mode 100644 index 0000000..52f9db1 Binary files /dev/null and b/Assets/OP-Blue-Text.png differ diff --git a/Assets/OP-Blue-Text.svg b/Assets/OP-Blue-Text.svg new file mode 100644 index 0000000..6b5b3c9 --- /dev/null +++ b/Assets/OP-Blue-Text.svg @@ -0,0 +1,13 @@ + + + OP-Blue-Text + + + + + + OP +OP + + + \ No newline at end of file diff --git a/Assets/OP-Blue.svg b/Assets/OP-Blue.svg new file mode 100644 index 0000000..e8aaef7 --- /dev/null +++ b/Assets/OP-Blue.svg @@ -0,0 +1,5 @@ + + + OP-Blue + + \ No newline at end of file diff --git a/Assets/OP-Gradient-Text.png b/Assets/OP-Gradient-Text.png new file mode 100644 index 0000000..4475320 Binary files /dev/null and b/Assets/OP-Gradient-Text.png differ diff --git a/Assets/OP-Gradient-Text.svg b/Assets/OP-Gradient-Text.svg new file mode 100644 index 0000000..95f7ebd --- /dev/null +++ b/Assets/OP-Gradient-Text.svg @@ -0,0 +1,20 @@ + + + + + + + + + + OP-Gradient-Text + + + + + + OP +OP + + + \ No newline at end of file diff --git a/Assets/OP-Gradient.svg b/Assets/OP-Gradient.svg new file mode 100644 index 0000000..3420d4f --- /dev/null +++ b/Assets/OP-Gradient.svg @@ -0,0 +1,12 @@ + + + + + + + + + + OP-Gradient + + \ No newline at end of file diff --git a/Assets/OP-Text.png b/Assets/OP-Text.png new file mode 100644 index 0000000..20fbb5a Binary files /dev/null and b/Assets/OP-Text.png differ diff --git a/Assets/OP-Text.svg b/Assets/OP-Text.svg new file mode 100644 index 0000000..b4ccd89 --- /dev/null +++ b/Assets/OP-Text.svg @@ -0,0 +1,13 @@ + + + OP-Text + + + + + + OP +OP + + + \ No newline at end of file diff --git a/Assets/OP.png b/Assets/OP.png new file mode 100644 index 0000000..f76b435 Binary files /dev/null and b/Assets/OP.png differ diff --git a/Assets/OP.svg b/Assets/OP.svg new file mode 100644 index 0000000..895db17 --- /dev/null +++ b/Assets/OP.svg @@ -0,0 +1,5 @@ + + + OP + + \ No newline at end of file diff --git a/Build/GitHub/Actions/OPAction.ps1 b/Build/GitHub/Actions/OPAction.ps1 new file mode 100644 index 0000000..dfc5c5d --- /dev/null +++ b/Build/GitHub/Actions/OPAction.ps1 @@ -0,0 +1,315 @@ +<# +.Synopsis + GitHub Action for OP +.Description + GitHub Action for OP. This will: + + * Import OP + * If `-Run` is provided, run that script + * Otherwise, unless `-SkipScriptFile` is passed, run all *.OP.ps1 files beneath the workflow directory + * If any `-ActionScript` was provided, run scripts from the action path that match a wildcard pattern. + + If you will be making changes using the GitHubAPI, you should provide a -GitHubToken + If none is provided, and ENV:GITHUB_TOKEN is set, this will be used instead. + Any files changed can be outputted by the script, and those changes can be checked back into the repo. + Make sure to use the "persistCredentials" option with checkout. +#> + +param( +# A PowerShell Script that uses OP. +# Any files outputted from the script will be added to the repository. +# If those files have a .Message attached to them, they will be committed with that message. +[string] +$Run, + +# If set, will not process any files named *.OP.ps1 +[switch] +$SkipScriptFile, + +# A list of modules to be installed from the PowerShell gallery before scripts run. +[string[]] +$InstallModule, + +# The name of one or more scripts to run, from this action's path. +[string[]] +$ActionScript, + +# The github token to use for requests. +[string] +$GitHubToken = '{{ secrets.GITHUB_TOKEN }}', + +# The user email associated with a git commit. +# If this is not provided, it will be set to the id+username@users.noreply.github.com. +[string] +$UserEmail, + +# The user name associated with a git commit. +# If this is not provided, it will be set to the $env:GITHUB_ACTOR +[string] +$UserName, + +# If set, will push any changes made to the repository. +# (they will still be committed unless `-NoCommit` is passed) +[switch] +$Push +) + +$ErrorActionPreference = 'continue' +"::group::Parameters" | Out-Host +[PSCustomObject]$PSBoundParameters | Format-List | Out-Host +"::endgroup::" | Out-Host + +$gitHubEventJson = [IO.File]::ReadAllText($env:GITHUB_EVENT_PATH) +$gitHubEvent = + if ($env:GITHUB_EVENT_PATH) { + $gitHubEventJson | ConvertFrom-Json + } else { $null } +"::group::Parameters" | Out-Host +$gitHubEvent | Format-List | Out-Host +"::endgroup::" | Out-Host + + +$anyFilesChanged = $false +$ActionModuleName = 'OP' +$actorInfo = $null + +if ($GitHubToken) { + $env:GH_TOKEN = $GitHubToken +} + + +$checkDetached = git symbolic-ref -q HEAD +if ($LASTEXITCODE) { + "::warning::On detached head, skipping action" | Out-Host + exit 0 +} + +function InstallActionModule { + param([string]$ModuleToInstall) + $moduleInWorkspace = Get-ChildItem -Path $env:GITHUB_WORKSPACE -Recurse -File | + Where-Object Name -eq "$($moduleToInstall).psd1" | + Where-Object { + $(Get-Content $_.FullName -Raw) -match 'ModuleVersion' + } + if (-not $moduleInWorkspace) { + $availableModules = Get-Module -ListAvailable + if ($availableModules.Name -notcontains $moduleToInstall) { + Install-Module $moduleToInstall -Scope CurrentUser -Force -AcceptLicense -AllowClobber + } + Import-Module $moduleToInstall -Force -PassThru | Out-Host + } else { + Import-Module $moduleInWorkspace.FullName -Force -PassThru | Out-Host + } +} +function ImportActionModule { + #region -InstallModule + if ($InstallModule) { + "::group::Installing Modules" | Out-Host + foreach ($moduleToInstall in $InstallModule) { + InstallActionModule -ModuleToInstall $moduleToInstall + } + "::endgroup::" | Out-Host + } + #endregion -InstallModule + + if ($env:GITHUB_ACTION_PATH) { + $LocalModulePath = Join-Path $env:GITHUB_ACTION_PATH "$ActionModuleName.psd1" + if (Test-path $LocalModulePath) { + Import-Module $LocalModulePath -Force -PassThru | Out-String + } else { + throw "Module '$ActionModuleName' not found" + } + } elseif (-not (Get-Module $ActionModuleName)) { + throw "Module '$ActionModuleName' not found" + } + + "::notice title=ModuleLoaded::$ActionModuleName Loaded from Path - $($LocalModulePath)" | Out-Host + if ($env:GITHUB_STEP_SUMMARY) { + "# $($ActionModuleName)" | + Out-File -Append -FilePath $env:GITHUB_STEP_SUMMARY + } +} +function InitializeAction { + #region Custom + #endregion Custom + + # Configure git based on the $env:GITHUB_ACTOR + if (-not $UserName) { $UserName = $env:GITHUB_ACTOR } + if (-not $actorID) { $actorID = $env:GITHUB_ACTOR_ID } + if (-not $UserEmail) { $UserEmail = "$actorID+$UserName@users.noreply.github.com" } + git config --global user.email $UserEmail + git config --global user.name $UserName + + # Pull down any changes + git pull | Out-Host +} + +function InvokeActionModule { + $myScriptStart = [DateTime]::Now + $myScript = $ExecutionContext.SessionState.PSVariable.Get("Run").Value + if ($myScript) { + $myScript > ./_run.ps1 + . ./_run.ps1 | + . ProcessOutput | + Out-Host + Remove-Item ./_run.ps1 + return + } + $myScriptTook = [Datetime]::Now - $myScriptStart + $MyScriptFilesStart = [DateTime]::Now + + $myScriptList = @() + $shouldSkip = $ExecutionContext.SessionState.PSVariable.Get("SkipScriptFile").Value + if ($shouldSkip) { + return + } + $scriptFiles = @( + Get-ChildItem -Recurse -Path $env:GITHUB_WORKSPACE | + Where-Object Name -Match "\.$($ActionModuleName)\.ps1$" + if ($ActionScript) { + if ($ActionScript -match '^\s{0,}/' -and $ActionScript -match '/\s{0,}$') { + $ActionScriptPattern = $ActionScript.Trim('/').Trim() -as [regex] + if ($ActionScriptPattern) { + $ActionScriptPattern = [regex]::new($ActionScript.Trim('/').Trim(), 'IgnoreCase,IgnorePatternWhitespace', [timespan]::FromSeconds(0.5)) + Get-ChildItem -Recurse -Path $env:GITHUB_ACTION_PATH | + Where-Object { $_.Name -Match "\.$($ActionModuleName)\.ps1$" -and $_.FullName -match $ActionScriptPattern } + } + } else { + Get-ChildItem -Recurse -Path $env:GITHUB_ACTION_PATH | + Where-Object Name -Match "\.$($ActionModuleName)\.ps1$" | + Where-Object FullName -Like $ActionScript + } + } + ) | Select-Object -Unique + $scriptFiles | + ForEach-Object -Begin { + if ($env:GITHUB_STEP_SUMMARY) { + "## $ActionModuleName Scripts" | + Out-File -Append -FilePath $env:GITHUB_STEP_SUMMARY + } + } -Process { + $myScriptList += $_.FullName.Replace($env:GITHUB_WORKSPACE, '').TrimStart('/') + $myScriptCount++ + $scriptFile = $_ + if ($env:GITHUB_STEP_SUMMARY) { + "### $($scriptFile.Fullname -replace [Regex]::Escape($env:GITHUB_WORKSPACE))" | + Out-File -Append -FilePath $env:GITHUB_STEP_SUMMARY + } + $scriptCmd = $ExecutionContext.SessionState.InvokeCommand.GetCommand($scriptFile.FullName, 'ExternalScript') + foreach ($requiredModule in $CommandInfo.ScriptBlock.Ast.ScriptRequirements.RequiredModules) { + if ($requiredModule.Name -and + (-not $requiredModule.MaximumVersion) -and + (-not $requiredModule.RequiredVersion) + ) { + InstallActionModule $requiredModule.Name + } + } + Push-Location $scriptFile.Directory.Fullname + $scriptFileOutputs = . $scriptCmd + $scriptFileOutputs | + . ProcessOutput | + Out-Host + Pop-Location + } + + $MyScriptFilesTook = [Datetime]::Now - $MyScriptFilesStart + $SummaryOfMyScripts = "$myScriptCount $ActionModuleName scripts took $($MyScriptFilesTook.TotalSeconds) seconds" + $SummaryOfMyScripts | + Out-Host + if ($env:GITHUB_STEP_SUMMARY) { + $SummaryOfMyScripts | + Out-File -Append -FilePath $env:GITHUB_STEP_SUMMARY + } + #region Custom + #endregion Custom +} + +function OutError { + $anyRuntimeExceptions = $false + foreach ($err in $error) { + $errParts = @( + "::error " + @( + if ($err.InvocationInfo.ScriptName) { + "file=$($err.InvocationInfo.ScriptName)" + } + if ($err.InvocationInfo.ScriptLineNumber -ge 1) { + "line=$($err.InvocationInfo.ScriptLineNumber)" + if ($err.InvocationInfo.OffsetInLine -ge 1) { + "col=$($err.InvocationInfo.OffsetInLine)" + } + } + if ($err.CategoryInfo.Activity) { + "title=$($err.CategoryInfo.Activity)" + } + ) -join ',' + "::" + $err.Exception.Message + if ($err.CategoryInfo.Category -eq 'OperationStopped' -and + $err.CategoryInfo.Reason -eq 'RuntimeException') { + $anyRuntimeExceptions = $true + } + ) -join '' + $errParts | Out-Host + if ($anyRuntimeExceptions) { + exit 1 + } + } +} + +function PushActionOutput { + if ($anyFilesChanged) { + "::notice::$($anyFilesChanged) Files Changed" | Out-Host + } + if ($anyFilesChanged) { + $checkDetached = git symbolic-ref -q HEAD + if (-not $LASTEXITCODE -and $Push -and -not $noCommit) { + if ($anyFilesChanged) { + "::notice::Pushing Changes" | Out-Host + git push + } + "Git Push Output: $($gitPushed | Out-String)" + } else { + "::notice::Not pushing changes (on detached head)" | Out-Host + $LASTEXITCODE = 0 + exit 0 + } + } +} + +filter ProcessOutput { + $out = $_ + $outItem = Get-Item -Path $out -ErrorAction Ignore + if (-not $outItem -and $out -is [string]) { + $out | Out-Host + if ($env:GITHUB_STEP_SUMMARY) { + "> $out" | Out-File -Append -FilePath $env:GITHUB_STEP_SUMMARY + } + return + } + $fullName, $shouldCommit = + if ($out -is [IO.FileInfo]) { + $out.FullName, (git status $out.Fullname -s) + } elseif ($outItem) { + $outItem.FullName, (git status $outItem.Fullname -s) + } + if ($shouldCommit -and -not $NoCommit) { + "$fullName has changed, and should be committed" | Out-Host + if ($Push) { + git add $fullName + if ($out.Message) { + git commit -m "$($out.Message)" | Out-Host + } elseif ($out.CommitMessage) { + git commit -m "$($out.CommitMessage)" | Out-Host + } + } + $anyFilesChanged = $true + } + $out +} + +. ImportActionModule +. InitializeAction +. InvokeActionModule +. PushActionOutput +. OutError \ No newline at end of file diff --git a/Build/GitHub/Jobs/BuildOP.psd1 b/Build/GitHub/Jobs/BuildOP.psd1 new file mode 100644 index 0000000..1907326 --- /dev/null +++ b/Build/GitHub/Jobs/BuildOP.psd1 @@ -0,0 +1,28 @@ +@{ + "runs-on" = "ubuntu-latest" + if = '${{ success() }}' + steps = @( + @{ + name = 'Check out repository' + uses = 'actions/checkout@main' + } + 'RunEZOut' + @{ + name = 'Run Action (on branch)' + if = '${{github.ref_name != ''main''}}' + uses = './' + id = 'OPAction' + } + @{ + uses = 'actions/upload-artifact@main' + 'id' = 'artifact-upload-step' + 'with' = @{ + name = 'op-test' + path = @' +OP.zip +_site +'@ + } + } + ) +} \ No newline at end of file diff --git a/Build/GitHub/Steps/PublishTestResults.psd1 b/Build/GitHub/Steps/PublishTestResults.psd1 new file mode 100644 index 0000000..e8111e8 --- /dev/null +++ b/Build/GitHub/Steps/PublishTestResults.psd1 @@ -0,0 +1,10 @@ +@{ + name = 'PublishTestResults' + uses = 'actions/upload-artifact@main' + with = @{ + name = 'PesterResults' + path = '**.TestResults.xml' + } + if = '${{always()}}' +} + diff --git a/Build/OP.GitHubAction.PSDevOps.ps1 b/Build/OP.GitHubAction.PSDevOps.ps1 new file mode 100644 index 0000000..27dda92 --- /dev/null +++ b/Build/OP.GitHubAction.PSDevOps.ps1 @@ -0,0 +1,10 @@ +#requires -Module PSDevOps +Import-BuildStep -SourcePath ( + Join-Path $PSScriptRoot 'GitHub' +) -BuildSystem GitHubAction + +Push-Location ($PSScriptRoot | Split-Path) +New-GitHubAction -Name "OpenPackage" -Description @' +Open Package Action - Open anything as a package +'@ -Action OPAction -Icon code -OutputPath .\action.yml +Pop-Location \ No newline at end of file diff --git a/Build/OP.GitHubWorkflow.PSDevOps.ps1 b/Build/OP.GitHubWorkflow.PSDevOps.ps1 new file mode 100644 index 0000000..688995b --- /dev/null +++ b/Build/OP.GitHubWorkflow.PSDevOps.ps1 @@ -0,0 +1,15 @@ +#requires -Module PSDevOps +Import-BuildStep -SourcePath ( + Join-Path $PSScriptRoot 'GitHub' +) -BuildSystem GitHubWorkflow + +Push-Location ($PSScriptRoot | Split-Path) +New-GitHubWorkflow -Name "Build OP Module" -On Push, + PullRequest, + Demand -Job TestPowerShellOnLinux, + TagReleaseAndPublish, BuildOP -Environment ([Ordered]@{ + REGISTRY = 'ghcr.io' + IMAGE_NAME = '${{ github.repository }}' + }) -OutputPath .\.github\workflows\BuildOP.yml + +Pop-Location \ No newline at end of file diff --git a/Build/OP.ezout.ps1 b/Build/OP.ezout.ps1 new file mode 100644 index 0000000..bd5f12f --- /dev/null +++ b/Build/OP.ezout.ps1 @@ -0,0 +1,39 @@ +#requires -Module EZOut +# Install-Module EZOut or https://github.com/StartAutomating/EZOut +$myFile = $MyInvocation.MyCommand.ScriptBlock.File +$myRoot = $myFile | Split-Path | Split-Path +$myModuleName = $myFile | Split-Path | Split-Path | Split-Path -Leaf +Push-Location $myRoot +$formatting = @( + # Add your own Write-FormatView here, + # or put them in a Formatting or Views directory + foreach ($potentialDirectory in 'Formatting','Views') { + Join-Path $myRoot $potentialDirectory | + Get-ChildItem -ea ignore | + Import-FormatView -FilePath {$_.Fullname} + } +) + +$destinationRoot = $myRoot + +if ($formatting) { + $myFormatFilePath = Join-Path $destinationRoot "$myModuleName.format.ps1xml" + # You can also output to multiple paths by passing a hashtable to -OutputPath. + $formatting | Out-FormatData -Module $MyModuleName -OutputPath $myFormatFilePath +} + +$types = @( + # Add your own Write-TypeView statements here + # or declare them in the 'Types' directory + Join-Path $myRoot Types | + Get-Item -ea ignore | + Import-TypeView + +) + +if ($types) { + $myTypesFilePath = Join-Path $destinationRoot "$myModuleName.types.ps1xml" + # You can also output to multiple paths by passing a hashtable to -OutputPath. + $types | Out-TypeData -OutputPath $myTypesFilePath +} +Pop-Location diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..85de8f6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,192 @@ +## OP 0.1 + +Initial Release of OP. + +* [#1](https://github.com/PoshWeb/OP/issues/1) +* [#2](https://github.com/PoshWeb/OP/issues/2) +* [#3](https://github.com/PoshWeb/OP/issues/3) +* [#5](https://github.com/PoshWeb/OP/issues/5) +* [#7](https://github.com/PoshWeb/OP/issues/7) +* [#8](https://github.com/PoshWeb/OP/issues/8) +* [#10](https://github.com/PoshWeb/OP/issues/10) +* [#11](https://github.com/PoshWeb/OP/issues/11) +* [#12](https://github.com/PoshWeb/OP/issues/12) +* [#13](https://github.com/PoshWeb/OP/issues/13) +* [#14](https://github.com/PoshWeb/OP/issues/14) +* [#15](https://github.com/PoshWeb/OP/issues/15) +* [#16](https://github.com/PoshWeb/OP/issues/16) +* [#17](https://github.com/PoshWeb/OP/issues/17) +* [#18](https://github.com/PoshWeb/OP/issues/18) +* [#19](https://github.com/PoshWeb/OP/issues/19) +* [#20](https://github.com/PoshWeb/OP/issues/20) +* [#21](https://github.com/PoshWeb/OP/issues/21) +* [#22](https://github.com/PoshWeb/OP/issues/22) +* [#23](https://github.com/PoshWeb/OP/issues/23) +* [#24](https://github.com/PoshWeb/OP/issues/24) +* [#25](https://github.com/PoshWeb/OP/issues/25) +* [#26](https://github.com/PoshWeb/OP/issues/26) +* [#27](https://github.com/PoshWeb/OP/issues/27) +* [#28](https://github.com/PoshWeb/OP/issues/28) +* [#29](https://github.com/PoshWeb/OP/issues/29) +* [#30](https://github.com/PoshWeb/OP/issues/30) +* [#31](https://github.com/PoshWeb/OP/issues/31) +* [#32](https://github.com/PoshWeb/OP/issues/32) +* [#33](https://github.com/PoshWeb/OP/issues/33) +* [#34](https://github.com/PoshWeb/OP/issues/34) +* [#35](https://github.com/PoshWeb/OP/issues/35) +* [#38](https://github.com/PoshWeb/OP/issues/38) +* [#39](https://github.com/PoshWeb/OP/issues/39) +* [#40](https://github.com/PoshWeb/OP/issues/40) +* [#41](https://github.com/PoshWeb/OP/issues/41) +* [#44](https://github.com/PoshWeb/OP/issues/44) +* [#45](https://github.com/PoshWeb/OP/issues/45) +* [#46](https://github.com/PoshWeb/OP/issues/46) +* [#47](https://github.com/PoshWeb/OP/issues/47) +* [#48](https://github.com/PoshWeb/OP/issues/48) +* [#49](https://github.com/PoshWeb/OP/issues/49) +* [#50](https://github.com/PoshWeb/OP/issues/50) +* [#51](https://github.com/PoshWeb/OP/issues/51) +* [#52](https://github.com/PoshWeb/OP/issues/52) +* [#53](https://github.com/PoshWeb/OP/issues/53) +* [#57](https://github.com/PoshWeb/OP/issues/57) +* [#58](https://github.com/PoshWeb/OP/issues/58) +* [#59](https://github.com/PoshWeb/OP/issues/59) +* [#61](https://github.com/PoshWeb/OP/issues/61) +* [#63](https://github.com/PoshWeb/OP/issues/63) +* [#64](https://github.com/PoshWeb/OP/issues/64) +* [#65](https://github.com/PoshWeb/OP/issues/65) +* [#67](https://github.com/PoshWeb/OP/issues/67) +* [#68](https://github.com/PoshWeb/OP/issues/68) +* [#74](https://github.com/PoshWeb/OP/issues/74) +* [#78](https://github.com/PoshWeb/OP/issues/78) +* [#80](https://github.com/PoshWeb/OP/issues/80) +* [#81](https://github.com/PoshWeb/OP/issues/81) +* [#82](https://github.com/PoshWeb/OP/issues/82) +* [#83](https://github.com/PoshWeb/OP/issues/83) +* [#84](https://github.com/PoshWeb/OP/issues/84) +* [#85](https://github.com/PoshWeb/OP/issues/85) +* [#86](https://github.com/PoshWeb/OP/issues/86) +* [#87](https://github.com/PoshWeb/OP/issues/87) +* [#88](https://github.com/PoshWeb/OP/issues/88) +* [#89](https://github.com/PoshWeb/OP/issues/89) +* [#90](https://github.com/PoshWeb/OP/issues/90) +* [#91](https://github.com/PoshWeb/OP/issues/91) +* [#92](https://github.com/PoshWeb/OP/issues/92) +* [#93](https://github.com/PoshWeb/OP/issues/93) +* [#94](https://github.com/PoshWeb/OP/issues/94) +* [#95](https://github.com/PoshWeb/OP/issues/95) +* [#96](https://github.com/PoshWeb/OP/issues/96) +* [#97](https://github.com/PoshWeb/OP/issues/97) +* [#98](https://github.com/PoshWeb/OP/issues/98) +* [#99](https://github.com/PoshWeb/OP/issues/99) +* [#100](https://github.com/PoshWeb/OP/issues/100) +* [#101](https://github.com/PoshWeb/OP/issues/101) +* [#102](https://github.com/PoshWeb/OP/issues/102) +* [#103](https://github.com/PoshWeb/OP/issues/103) +* [#104](https://github.com/PoshWeb/OP/issues/104) +* [#105](https://github.com/PoshWeb/OP/issues/105) +* [#106](https://github.com/PoshWeb/OP/issues/106) +* [#107](https://github.com/PoshWeb/OP/issues/107) +* [#108](https://github.com/PoshWeb/OP/issues/108) +* [#109](https://github.com/PoshWeb/OP/issues/109) +* [#110](https://github.com/PoshWeb/OP/issues/110) +* [#111](https://github.com/PoshWeb/OP/issues/111) +* [#112](https://github.com/PoshWeb/OP/issues/112) +* [#113](https://github.com/PoshWeb/OP/issues/113) +* [#114](https://github.com/PoshWeb/OP/issues/114) +* [#115](https://github.com/PoshWeb/OP/issues/115) +* [#116](https://github.com/PoshWeb/OP/issues/116) +* [#117](https://github.com/PoshWeb/OP/issues/117) +* [#118](https://github.com/PoshWeb/OP/issues/118) +* [#119](https://github.com/PoshWeb/OP/issues/119) +* [#120](https://github.com/PoshWeb/OP/issues/120) +* [#121](https://github.com/PoshWeb/OP/issues/121) +* [#122](https://github.com/PoshWeb/OP/issues/122) +* [#123](https://github.com/PoshWeb/OP/issues/123) +* [#124](https://github.com/PoshWeb/OP/issues/124) +* [#125](https://github.com/PoshWeb/OP/issues/125) +* [#126](https://github.com/PoshWeb/OP/issues/126) +* [#127](https://github.com/PoshWeb/OP/issues/127) +* [#128](https://github.com/PoshWeb/OP/issues/128) +* [#129](https://github.com/PoshWeb/OP/issues/129) +* [#130](https://github.com/PoshWeb/OP/issues/130) +* [#131](https://github.com/PoshWeb/OP/issues/131) +* [#132](https://github.com/PoshWeb/OP/issues/132) +* [#133](https://github.com/PoshWeb/OP/issues/133) +* [#134](https://github.com/PoshWeb/OP/issues/134) +* [#135](https://github.com/PoshWeb/OP/issues/135) +* [#136](https://github.com/PoshWeb/OP/issues/136) +* [#137](https://github.com/PoshWeb/OP/issues/137) +* [#138](https://github.com/PoshWeb/OP/issues/138) +* [#139](https://github.com/PoshWeb/OP/issues/139) +* [#140](https://github.com/PoshWeb/OP/issues/140) +* [#141](https://github.com/PoshWeb/OP/issues/141) +* [#142](https://github.com/PoshWeb/OP/issues/142) +* [#143](https://github.com/PoshWeb/OP/issues/143) +* [#144](https://github.com/PoshWeb/OP/issues/144) +* [#145](https://github.com/PoshWeb/OP/issues/145) +* [#146](https://github.com/PoshWeb/OP/issues/146) +* [#147](https://github.com/PoshWeb/OP/issues/147) +* [#148](https://github.com/PoshWeb/OP/issues/148) +* [#149](https://github.com/PoshWeb/OP/issues/149) +* [#150](https://github.com/PoshWeb/OP/issues/150) +* [#151](https://github.com/PoshWeb/OP/issues/151) +* [#152](https://github.com/PoshWeb/OP/issues/152) +* [#153](https://github.com/PoshWeb/OP/issues/153) +* [#154](https://github.com/PoshWeb/OP/issues/154) +* [#155](https://github.com/PoshWeb/OP/issues/155) +* [#156](https://github.com/PoshWeb/OP/issues/156) +* [#157](https://github.com/PoshWeb/OP/issues/157) +* [#158](https://github.com/PoshWeb/OP/issues/158) +* [#159](https://github.com/PoshWeb/OP/issues/159) +* [#160](https://github.com/PoshWeb/OP/issues/160) +* [#161](https://github.com/PoshWeb/OP/issues/161) +* [#162](https://github.com/PoshWeb/OP/issues/162) +* [#163](https://github.com/PoshWeb/OP/issues/163) +* [#164](https://github.com/PoshWeb/OP/issues/164) +* [#165](https://github.com/PoshWeb/OP/issues/165) +* [#166](https://github.com/PoshWeb/OP/issues/166) +* [#167](https://github.com/PoshWeb/OP/issues/167) +* [#169](https://github.com/PoshWeb/OP/issues/169) +* [#170](https://github.com/PoshWeb/OP/issues/170) +* [#171](https://github.com/PoshWeb/OP/issues/171) +* [#172](https://github.com/PoshWeb/OP/issues/172) +* [#173](https://github.com/PoshWeb/OP/issues/173) +* [#174](https://github.com/PoshWeb/OP/issues/174) +* [#175](https://github.com/PoshWeb/OP/issues/175) +* [#176](https://github.com/PoshWeb/OP/issues/176) +* [#177](https://github.com/PoshWeb/OP/issues/177) +* [#178](https://github.com/PoshWeb/OP/issues/178) +* [#179](https://github.com/PoshWeb/OP/issues/179) +* [#180](https://github.com/PoshWeb/OP/issues/180) +* [#181](https://github.com/PoshWeb/OP/issues/181) +* [#182](https://github.com/PoshWeb/OP/issues/182) +* [#183](https://github.com/PoshWeb/OP/issues/183) +* [#184](https://github.com/PoshWeb/OP/issues/184) +* [#185](https://github.com/PoshWeb/OP/issues/185) +* [#186](https://github.com/PoshWeb/OP/issues/186) +* [#187](https://github.com/PoshWeb/OP/issues/187) +* [#188](https://github.com/PoshWeb/OP/issues/188) +* [#189](https://github.com/PoshWeb/OP/issues/189) +* [#190](https://github.com/PoshWeb/OP/issues/190) +* [#191](https://github.com/PoshWeb/OP/issues/191) +* [#192](https://github.com/PoshWeb/OP/issues/192) +* [#193](https://github.com/PoshWeb/OP/issues/193) +* [#194](https://github.com/PoshWeb/OP/issues/194) +* [#195](https://github.com/PoshWeb/OP/issues/195) +* [#196](https://github.com/PoshWeb/OP/issues/196) +* [#197](https://github.com/PoshWeb/OP/issues/197) +* [#198](https://github.com/PoshWeb/OP/issues/198) +* [#199](https://github.com/PoshWeb/OP/issues/199) +* [#200](https://github.com/PoshWeb/OP/issues/200) +* [#202](https://github.com/PoshWeb/OP/issues/202) +* [#203](https://github.com/PoshWeb/OP/issues/203) +* [#204](https://github.com/PoshWeb/OP/issues/204) +* [#205](https://github.com/PoshWeb/OP/issues/205) +* [#206](https://github.com/PoshWeb/OP/issues/206) +* [#207](https://github.com/PoshWeb/OP/issues/207) +* [#208](https://github.com/PoshWeb/OP/issues/208) +* [#211](https://github.com/PoshWeb/OP/issues/211) +* [#212](https://github.com/PoshWeb/OP/issues/212) +* [#213](https://github.com/PoshWeb/OP/issues/213) + diff --git a/Commands/Close-OpenPackage.ps1 b/Commands/Close-OpenPackage.ps1 new file mode 100644 index 0000000..9f29485 --- /dev/null +++ b/Commands/Close-OpenPackage.ps1 @@ -0,0 +1,47 @@ +function Lock-OpenPackage +{ + <# + .SYNOPSIS + Closes an Open Package + .DESCRIPTION + Closes an Open Package. + .NOTES + This will free the package from memory + .EXAMPLE + # Import OP, make it a package, and lock it + Import-Module OP -PassThru | + Get-OpenPackage | + Close-OpenPackage + .EXAMPLE + # Import OP, make it a package, and lock it + impo OP -PassThru | op | Close-OpenPackage + #> + [CmdletBinding(ConfirmImpact='Medium')] + [Alias('Close-OP', 'csop','csOpenPackage')] + param( + # The input object. This should be a package. + [Parameter(ValueFromPipeline,ValueFromPipelineByPropertyName)] + [Alias('Package')] + [PSObject] + $InputObject + ) + + process { + # If the input is not a package + if ($InputObject -isnot [IO.Packaging.Package]) { + if ($inputObject.Package -is [IO.Packaging.Package]) { + $inputObject = $inputObject.Package + } else { + return $InputObject # pass it thru. + } + + } + + $InputObject.Close() # this also makes the current package useless + + if ($inputObject.MemoryStream -is [IO.MemoryStream]) { + $InputObject.MemoryStream.Close() + $InputObject.MemoryStream.Dispose() + } + } +} \ No newline at end of file diff --git a/Commands/Copy-OpenPackage.ps1 b/Commands/Copy-OpenPackage.ps1 new file mode 100644 index 0000000..f48e527 --- /dev/null +++ b/Commands/Copy-OpenPackage.ps1 @@ -0,0 +1,221 @@ +function Copy-OpenPackage +{ + <# + .SYNOPSIS + Copies Open Packages + .DESCRIPTION + Copies Contents from one packages to another. + .EXAMPLE + Copy-OpenPackage -DestinationPath ./Examples/Copy.docx -InputObject ./Examples/Sample.docx -Force + .LINK + Get-OpenPackage + .LINK + Select-OpenPackage + #> + [Alias('Copy-OP','cpop','cpOpenPackage')] + param( + # The destination. + # If this is not a `[IO.Packaging.Package]`, it will be considered a file path. + [Parameter(ValueFromPipelineByPropertyName)] + [Alias('DestinationPath')] + [PSObject] + $Destination, + + <# + Includes the specified parts. + + Enter a wildcard pattern, such as `*.txt` + + Wildcards are permitted. + #> + [ValidateNotNullOrEmpty()] + [SupportsWildcards()] + [string[]] + $Include, + + <# + Excludes the specified parts. + + Enter a wildcard pattern, such as `*.txt` + + Wildcards are permitted. + #> + [ValidateNotNullOrEmpty()] + [SupportsWildcards()] + [string[]] + $Exclude, + + <# + Includes the specified content types. + + Enter a wildcard pattern, such as `text/*` + #> + [ValidateNotNullOrEmpty()] + [SupportsWildcards()] + [string[]] + $IncludeContentType, + + <# + Excludes the specified content types. + + Enter a wildcard pattern, such as `text/*` + #> + [ValidateNotNullOrEmpty()] + [SupportsWildcards()] + [string[]] + $ExcludeContentType, + + # If set, will merge contents into an existing file. + [switch] + $Merge, + + # The input object + [Parameter(ValueFromPipeline)] + [PSObject] + $InputObject, + + # If set, will update existing packages. + [switch] + $Force + ) + + begin { + $selectOpenPackage = $ExecutionContext.SessionState.InvokeCommand.GetCommand('Select-OpenPackage','Function') + } + + process { + # If the input was not a package + if ($inputObject -isnot [IO.Packaging.Package]) { + $loadedPackage = # see if it is a file we can load + if ($InputObject -is [IO.FileInfo]) { + Get-OpenPackage $InputObject.FullName + } elseif ($inputFile = Get-Item -ErrorAction Ignore -Path "$InputObject") { + Get-OpenPackage $inputFile.FullName + } + + # If it was not, return. + if ($loadedPackage -isnot [IO.Packaging.Package]) { return } + $InputObject = $loadedPackage + } + + $destinationPackage = + if ($Destination -is [IO.Packaging.Package]) { + $Destination + } elseif ($Destination) { + # Get the absolute path of the destination, without creating the file, + $unresolvedDestination = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Destination) + + # then see if the file exists. + $fileExists = Test-Path $unresolvedDestination + # If it does and we are not using the -Force + if ($fileExists -and -not $force) { + # write an error + Write-Error "$unresolvedDestionation already exists, use -Force to overwrite" -Category ResourceExists + return + } + # If it did not exist, create it with New-Item -Force + elseif (-not $Merge) + { + # this will create intermediate paths. + $packageFile = New-Item -ItemType File -Path $unresolvedDestination -Force + if (-not $packageFile) { return } + } elseif ($Merge) { + $packageFile = Get-Item -ItemType File -Path $unresolvedDestination + } + # Try to open or create our package for read and write. + [IO.Packaging.Package]::Open($packageFile.FullName, 'OpenOrCreate', 'ReadWrite') + } else { + $memoryStream = [IO.MemoryStream]::new() + [IO.Packaging.Package]::Open($memoryStream, 'OpenOrCreate', 'ReadWrite') | + Add-Member NoteProperty MemoryStream $memoryStream -Force -PassThru + } + + # If we could not, we are done. + if (-not $destinationPackage) { return } + + # Start off with the package properties + foreach ($property in $InputObject.PackageProperties.psobject.properties) { + if ($property.IsSettable) { + $destinationPackage.PackageProperties.$($property.Name) = + $InputObject.PackageProperties.$($property.Name) + } + } + + # Get the input parts and relationships + $inputPackageParts = $InputObject.GetParts() + + $selectSplat = [Ordered]@{InputObject=$InputObject} + foreach ($key in $PSBoundParameters.Keys) { + if ($selectOpenPackage.Parameters[$key]) { + $selectSplat[$key] = $PSBoundParameters[$key] + } + } + $inputPackageParts = Select-OpenPackage @selectSplat + + $inputPackageRelationships = @($InputObject.GetRelationships()) + + # For each part in the input + foreach ($inputPart in $inputPackageParts) { + # Create or open a part in the destination + $destinationPart = + if (-not $destinationPackage.PartExists($inputPart.Uri)) { + $destinationPackage.CreatePart($inputPart.Uri, $inputPart.ContentType, $inputPart.CompressionOption) + } else { + $destinationPackage.GetPart($inputPart.Uri) + } + + # and copy the streams. + $inputStream = $inputPart.GetStream('Open', 'Read') + $destinationStream = $destinationPart.GetStream() + $inputStream.CopyTo($destinationStream) + $inputStream.Close() + $destinationStream.Close() + + $partRelationships = @( + try { + $inputPart.GetRelationships() + } catch { + # Relationships cannot have relationships + # Also, we can't copy any relationship that throws an exception. + # So simply ignore the error. + } + ) + if ($partRelationships) { + $inputPackageRelationships += $partRelationships + } + } + + # Then, create any relationships that do not exist. + foreach ($inputRelationship in $inputPackageRelationships) { + if ($inputRelationship.SourceUri -eq '/') { + if (-not $destinationPackage.RelationshipExists($inputRelationship.id)) { + $null = $destinationPackage.CreateRelationship( + $inputRelationship.targetUri, + $inputRelationship.targetMode, + $inputRelationship.relationshipType, + $inputRelationship.id + ) + } + } elseif ($inputRelationship.SourceUri -match '^/.') { + $destinationPackagePart = $destinationPackage.GetPart($inputRelationship.SourceUri) + if (-not $destinationPackagePart.RelationshipExists($inputRelationship.id)) { + $destinationPackagePart.CreateRelationship( + $inputRelationship.targetUri, + $inputRelationship.targetMode, + $inputRelationship.relationshipType, + $inputRelationship.id + ) + } + } + } + + if ($destination -and $Destination -isnot [IO.Packaging.Package]) { + # We can now close our package, writing the file. + $destinationPackage.Close() + # We want to open it right back up again as we output the updated file. + Get-OpenPackage -FilePath $unresolvedDestination + } else { + $destinationPackage + } + } +} \ No newline at end of file diff --git a/Commands/Export-OpenPackage.ps1 b/Commands/Export-OpenPackage.ps1 new file mode 100644 index 0000000..38e54f6 --- /dev/null +++ b/Commands/Export-OpenPackage.ps1 @@ -0,0 +1,95 @@ +function Export-OpenPackage { + <# + .SYNOPSIS + Exports OpenPackage packages + .DESCRIPTION + Exports loaded packages to a file or directory. + #> + [Alias( + 'Export-OP','epop','epOpenPackage', + 'Save-OpenPackage','Save-OP','svop', 'svOpenPackage' + )] + param( + # The package file path. + # If this has no extension, it will be considered a directory name. + # If the path already exists and is a directory, it will be considered a directory name. + [Parameter(Mandatory,ValueFromPipelineByPropertyName)] + [Alias('Path','FilePath','Fullname')] + [string] + $DestinationPath, + + <# + Includes the specified parts. + + Enter a wildcard pattern, such as `*.txt` + #> + [ValidateNotNullOrEmpty()] + [SupportsWildcards()] + [string[]] + $Include, + + <# + Excludes the specified parts. + + Enter a wildcard pattern, such as `*.txt` + #> + [ValidateNotNullOrEmpty()] + [SupportsWildcards()] + [string[]] + $Exclude, + + <# + Includes the specified content types. + + Enter a wildcard pattern, such as `text/*` + #> + [ValidateNotNullOrEmpty()] + [SupportsWildcards()] + [string[]] + $IncludeContentType, + + # The input object. + # This must be a package loaded with this module. + [Parameter(ValueFromPipeline)] + [Alias('Package')] + [PSObject] + $InputObject, + + # If set, will force the export even if a file already exists. + [switch] + $Force + ) + + process { + # If there is no input return + if (-not $InputObject) { return } + # If the input is not a package, pass it thru + if ($InputObject -isnot [IO.Packaging.Package]) { return $InputObject } + + # Get the item if it already exists + $destinationItem = Get-Item $DestinationPath -ErrorAction Ignore + + # Copy parameters to simply any future debugging. + $parameterCopy = [Ordered]@{} + $PSBoundParameters + + if ( + # If the destination is a directory + ($destinationItem -is [IO.DirectoryInfo]) -or + # Or the last segment has no extension. + (@($DestinationPath -split '[\\/]' -ne '')[-1] -notlike '*.*') + ) + { + # install the open package into that directory. + Install-OpenPackage @parameterCopy -PassThru + } else { + # Otherwise, copy the package to that path + $copiedPackage = Copy-OpenPackage @parameterCopy + # Get the output item + $outputFile = Get-Item $DestinationPath + # and add the package to the file. + $outputFile | + Add-Member NoteProperty Package $copiedPackage -Force -PassThru + } + } +} + diff --git a/Commands/Format-OpenPackage.ps1 b/Commands/Format-OpenPackage.ps1 new file mode 100644 index 0000000..b1bfcc1 --- /dev/null +++ b/Commands/Format-OpenPackage.ps1 @@ -0,0 +1,146 @@ +function Format-OpenPackage { + <# + .SYNOPSIS + Formats Open Package + .DESCRIPTION + Formats Open Packages using any view. + #> + [CmdletBinding(PositionalBinding=$false)] + [Alias('Format-OP', 'fop', 'fOpenPackage')] + param( + # The name of the view, or a view command or scriptblock + [Parameter(Mandatory,Position=0)] + [ArgumentCompleter({ + param ( $commandName, + $parameterName, + $wordToComplete, + $commandAst, + $fakeBoundParameters ) + $typeData = Get-TypeData -TypeName OpenPackage.View + + if (-not $wordToComplete) { + $typeData.Members.Keys + } else { + $typeData.Members.Keys -match ([Regex]::Escape($wordTocomplete)) + } + })] + [PSObject] + $View, + + # The Any Positional arguments for the view + [Parameter(Position=1,ValueFromRemainingArguments)] + [Alias('Argument','Arguments','Args')] + [PSObject[]] + $ArgumentList, + + # Any input objects. + [Parameter(ValueFromPipeline)] + [Alias('Package')] + [PSObject[]] + $InputObject, + + # Any options or parameters to pass to the View + [Alias('Options','Parameter','Parameters')] + [Collections.IDictionary] + $Option = [Ordered]@{} + ) + + # Gather all input + $allInput = @($input) + + if (-not $allInput -and $InputObject) { + $allInput += $InputObject + } + + # Get the typedata that describes Formatters + $typeData = Get-TypeData -TypeName OpenPackage.View + $commandName = '' + + # If the view is a string + if ($View -is [string]) { + # check for a view with that name + if ($typeData.Members[$View].Script) { + # if one is found, use that + $commandName = $View + $view = $typeData.Members[$View].Script + } else { + # Othewise, try to find a Formatter command + $ViewCommand = + $ExecutionContext.SessionState.InvokeCommand.GetCommand( + $View,'Cmdlet,Alias,Function' + ) + # If we found one, try to use it + if ($ViewCommand) { + $commandName = $View + $View = $ViewCommand + } else { + # Otherwise, warn that Formatter is unknown + Write-Warning "Unknown View $view" + # and break out of the loop. + return + } + } + } + + # If the view is not a script or command + if ($view -isnot [ScriptBlock] -and + $view -isnot [Management.Automation.CommandInfo] + ) { + # return + Write-Error "View must be a Name of view, ScriptBlock, or Command" + return + } + + # Get our command metadata. + $commandMetaData = + if ($view -is [ScriptBlock]) { + # If the view a scriptblock + # make a temporary function + $function:View = $view + # get its metadata + $ExecutionContext.SessionState.InvokeCommand.GetCommand('view', 'Function') -as + [Management.Automation.CommandMetaData] + # and remove the temporary function + Remove-Item 'function:view' + } else { + # otherwise, just cast to command metadata + $commandName = $view.Name + $view -as [Management.Automation.CommandMetaData] + } + + # Once we have commandmetadata, we can find parameter names. + $validParameterNames = @( + $commandMetaData.Parameters.Keys + $commandMetaData.Parameters.Values.Aliases + ) + + # We want to be forgiving with input, so copy the options + $commandParameters = [Ordered]@{} + $Option + # Check each key + foreach ($key in @($commandParameters.Keys)) { + # If we have valid parameter names + if ($validParameterNames -and + # and this isn't one of them, + $validParameterNames -notcontains $key + ) { + # write a warning + Write-Warning "Option $key not supported by $($commandName)" + # and remove the key. + $commandParameters.Remove($key) + } + } + + if ($allInput) { + if ($ArgumentList) { + $allInput | & $view @ArgumentList @commandParameters + } else { + $allInput | & $view @commandParameters + } + } else { + if ($ArgumentList) { + & $view @ArgumentList @commandParameters + } else { + & $view @commandParameters + } + } +} \ No newline at end of file diff --git a/Commands/Get-OpenPackage.ps1 b/Commands/Get-OpenPackage.ps1 new file mode 100644 index 0000000..4fdd1c3 --- /dev/null +++ b/Commands/Get-OpenPackage.ps1 @@ -0,0 +1,769 @@ +function Get-OpenPackage +{ + <# + .SYNOPSIS + Gets Open Packages + .LINK + https://en.wikipedia.org/wiki/Open_Packaging_Conventions + .INPUTS + Almost anything + .OUTPUTS + Open Packages + .DESCRIPTION + Gets Open Packages from almost anything. + + Anything can be a package. + + This command helps you make anything into a package. + + The following types of packages are currently supported: + + * Any [Open Packaging Convention](https://en.wikipedia.org/wiki/Open_Packaging_Conventions) files + * Any directory + * Any `*.zip` file + * Any `*.tar.gz` file + * Any url + * Any public nuget package + * Any git repository + * Any public at protocol URI + * Any dictionary (including nested dictionaries) + * Any single file + + Anything can be a package. + + Once we start to treat anything as a package, we can do amazing things with packages. + + Like: + + * Inspect any packages before we work with them. + * Modify the packages to customize their content. + * Split packages + * Filter our components. + * Join them back together. + * Search package content. + * Work with compressed trees of data. + * Have an in-memory containerized virtual filesystem. + * Serve a package from memory. + * Store data to N package layers. + .EXAMPLE + # Make the current directory into a package + # (do not try this at `$home`) + Get-OpenPackage . + .EXAMPLE + # Make the module into a package + $opPackage = Get-Module OP | Get-OpenPackage + .EXAMPLE + $opPackage = Get-Module OP | + Get-OpenPackage -Include *.ps1 + .EXAMPLE + # Another way to make the current directory into a package + # (do not try this at `$home`) + Get-Item . | Get-OpenPackage + .EXAMPLE + # Get a package from nuget + $Avalonia = OP https://www.nuget.org/packages/Avalonia/ + .EXAMPLE + # Get a package from Chocolatey + $chocoPackage = op https://community.chocolatey.org/packages/chocolatey + .EXAMPLE + # Get a package from the PowerShell gallery + $turtlePackage = op https://powershellgallery.com/packages/Turtle + .EXAMPLE + # Get a package from a single URL + $imagePackage = op https://MrPowerShell.com/MrPowerShell.png + .EXAMPLE + # Create a package from multiple URLs by piping back to ourself + $svgAndPng = op https://MrPowerShell.com/MrPowerShell.png | + op https://MrPowerShell.com/MrPowerShell.svg + .EXAMPLE + # Get a package from an at protocol URI + $atPost = op at://mrpowershell.com/app.bsky.feed.post/3k4hf5dy6nf2g + .EXAMPLE + # Get the most recent 50 posts + $atLast50 = op at://mrpowershell.com/app.bsky.feed.post/ -First 50 + .EXAMPLE + # Get all standard.site.documents for a user + $standardSiteDocuments = op at://mrpowershell.com/site.standard.document/ + .EXAMPLE + $MrPowerShellStandardSite = op @( + 'at://mrpowershell.com/site.standard.document/' + 'at://mrpowershell.com/site.standard.publication/' + ) + .EXAMPLE + $MrPowerShellStrings = op 'at://mrpowershell.com/sh.tangled.string/' + .EXAMPLE + # Get [feather icons](https://feathericons.com/) as an open package. + $featherIcons = Get-OpenPackage https://github.com/feathericons/feather/tree/main/icons + .EXAMPLE + # Get the [itermColorSchemes](https://iterm2colorschemes.com/) for windows terminal + $iTermPalettes = + op 'https://github.com/mbadolato/iTerm2-Color-Schemes/blob/master/windowsterminal/' + #> + [CmdletBinding(PositionalBinding=$false,DefaultParameterSetName='Any',SupportsPaging)] + [Alias( + 'Get-OP', 'OP', 'OpenPackage','gOpenPackage', + 'Open-OpenPackage', 'Open-OP', 'opop', 'opOpenPackage' + )] + param( + # Any unnamed arguments to the command. + # Each argument will be treated as a potential -FilePath or -Uri. + # Once the first related verb is detected, these will become arguments to that verb + # (For example, `op . start` will get an open package and then start a server for that package) + [Parameter(ValueFromRemainingArguments)] + [PSObject[]] + $ArgumentList, + + # The path of a file to import + [Parameter(Mandatory,ParameterSetName='FilePath',ValueFromPipelineByPropertyName)] + [Alias('Fullname')] + [string] + $FilePath, + + # Gets Open Packages with `@` syntax. + # Without a domain, `@` will be presumed to be a github + # With a domain, `@` will look for an https url, and check at protocol + [Parameter(Mandatory,ParameterSetName='At',ValueFromPipelineByPropertyName)] + [string[]] + $At, + + # A URI to package. + # If this URI is a git repository, will make a package out of the repository + # If this URI is a nuget package url or powershell gallery url, will download the package. + [Parameter(Mandatory,ParameterSetName='Uri',ValueFromPipelineByPropertyName)] + [Alias('Url')] + [uri[]] + $Uri, + + # Any additional headers to pass into a web request. + [Alias('Header')] + [Collections.IDictionary] + $Headers = [Ordered]@{}, + + # A Repository to package. + # This can be the root of a repo or a link to a portion of the tree. + # If a portion of the tree is provided, will perform a sparse clone of the repository + [Parameter(Mandatory,ParameterSetName='Repository',ValueFromPipelineByPropertyName)] + [Alias('clone_url')] + [string] + $Repository, + + # The github branch name. + [Parameter(ValueFromPipelineByPropertyName)] + [string] + $Branch, + + # One or more optional sparse filters to a repository. + # If these are provided, only files matching these filters will be downloaded. + [Parameter(ValueFromPipelineByPropertyName)] + [string[]] + $SparseFilter, + + # An At Uri to package. + # This can be a single post or a collection of all posts of a type. + [Parameter(Mandatory,ParameterSetName='AtUri',ValueFromPipelineByPropertyName)] + [string[]] + $AtUri, + + # The personal data server. This is used in At Protocol requests. + [Parameter(ValueFromPipelineByPropertyName)] + [string] + $PDS, + + # Adds a dictionary of content to the package + [Parameter(Mandatory,ParameterSetName='Dictionary',ValueFromPipelineByPropertyName)] + [Collections.IDictionary] + $Dictionary, + + # The base path within the package. + # Content should be added beneath this base path. + [string] + $BasePath, + + # A Nuget Uri to package. + # The package at this location will be downloaded and opened directly. + [Parameter(Mandatory,ParameterSetName='NugetPackage',ValueFromPipelineByPropertyName)] + [Alias('NugetPackage','PowerShellGallery','ChocolateyGallery')] + [uri] + $NuGet, + + # One or more Node Packages. + [Parameter(Mandatory,ParameterSetName='NodePackage',ValueFromPipelineByPropertyName)] + [Alias('npm','npmx')] + [string[]] + $NodePackage, + + # One or more Python Packages. + [Parameter(Mandatory,ParameterSetName='PythonPackage',ValueFromPipelineByPropertyName)] + [Alias('pip','whl')] + [string[]] + $PythonPackage, + + + # A module to package + # A loaded module name or moduleinfo object to package. + # The loaded module must have a path property. + # The files in this path will be packaged. + [Parameter(Mandatory,ParameterSetName='Module',ValueFromPipelineByPropertyName)] + [PSObject] + $Module, + + # A list of file wildcards to include. + [Parameter(ValueFromPipelineByPropertyName)] + [SupportsWildcards()] + [string[]] + $Include, + + # A list of file wildcards to exclude. + [Parameter(ValueFromPipelineByPropertyName)] + [SupportsWildcards()] + [string[]] + $Exclude, + + # A content type map. + # This maps extensions and URIs to a content type. + [Collections.IDictionary] + $TypeMap = $( + ([PSCustomObject]@{PSTypeName='OpenPackage.ContentTypeMap'}).TypeMap + ), + + # The compression option. + [IO.Packaging.CompressionOption] + [Alias('CompressionLevel')] + $CompressionOption = 'Superfast', + + # One or more input objects. + [Parameter(ValueFromPipeline)] + [Alias('Package')] + [PSObject] + $InputObject, + + # Gets the packages that are currently installed + [Parameter(Mandatory,ParameterSetName='Installed')] + [switch] + $Installed, + + # Gets packages that are currently running in a server + [Parameter(Mandatory,ParameterSetName='Running')] + [switch] + $Running, + + # If set, will force the redownload of various resources and remove existing files or directories + [switch] + $Force, + + # If set, will include hidden files and folders, except for files beneath `.git` + [Alias('IncludeDotFiles')] + [switch] + $IncludeHidden, + + # If set, will include the `.git` directory contents if found. + # By default, this content will be excluded. + [Alias('IncludeGitFile','IncludeGitFiles','IncludeGitDirectory')] + [switch] + $IncludeGit, + + # If set, will include any content found in `/node_modules`. + # By default, this content will be excluded. + [Alias('IncludeNodeModules')] + [switch] + $IncludeNodeModule, + + # If set, will include any content found in `/_site`. + # By default, this content will be excluded. + [Alias('IncludeWebsite')] + [switch] + $IncludeSite + ) + + begin { + # First, set output encoding to UTF8 + # This should ensure things work consistently regardless of operating system and user preference. + $OutputEncoding = [Text.Encoding]::UTF8 + + # And we want to keep track of our command name, so we can semi-anonymously recurse. + $myCommandName = $MyInvocation.MyCommand.Name + + # Next up we initialize a type map. + # This maps extensions and uris to a content type, and is used when creating parts. + $typeMap = ([PSCustomObject]@{PSTypeName='OpenPackage.ContentTypeMap'}).TypeMap + + # Gets the open package type data, as we will need this + $OpTypeData = Get-TypeData -TypeName OpenPackage.Source + + function InvokeOpMethod { + param([string]$Name, [Collections.IDictionary]$Parameter) + + if (-not $OpTypeData.Members[$Name].Script) { + return + } + $bindableParameters = [Ordered]@{} + $function:func = $OpTypeData.Members[$Name].Script + $func = $ExecutionContext.SessionState.InvokeCommand.GetCommand('func', 'Function') + + :nextParameter foreach ($parameterName in $func.Parameters.Keys) { + if ($null -ne $parameter[$parameterName]) { + $bindableParameters[$parameterName] = $Parameter[$parameterName] + continue nextParameter + } + foreach ($aliasName in $func.Parameters[$parameterName].Aliases) { + if ($null -ne $Parameter[$aliasName]) { + $bindableParameters[$aliasName] = $Parameter[$aliasName] + continue nextParameter + } + } + } + + $ExecutionContext.SessionState.PSVariable.Remove('function:func') + + try { + & $OpTypeData.Members[$Name].Script @bindableParameters + } catch { + $PSCmdlet.WriteError($_) + } + } + + + # The full default type map is much more robust. + # If this was being used without a module context, we still want to work for _most_ scenarios + # So this a secondary default, declared inline, that captures a fairly short list of common content types. + if (-not $typeMap -or -not $typeMap.Count) { + $typeMap = [Ordered]@{ + ".css" = "text/css"; ".html" = "text/html"; + ".svg" = "image/svg+xml"; '.js' = 'text/javascript';".jsm" = "text/javascript"; + ".png" = "image/png"; ".gif" = "image/gif"; + ".jpg" = "image/jpeg"; ".jpeg" = "image/jpeg"; + ".md" = "text/markdown" + ".mp3" = "audio/mpeg"; ".mp4" = "video/mp4"; + ".xml" = "application/xml" + } + } + + # Now declare several internal filters to turn various things into packages. + + # These are in alphabetical order, not in order of likely use. + filter getCurrentPack { + # Gets the current package + + # If the input object was a package, get it + if ($inputObject -is [IO.Packaging.Package]) { + $currentPackage = $InputObject + } else { + # Otherwise, + $memoryStream = [IO.MemoryStream]::new() + $currentPackage = [IO.Packaging.Package]::Open($memoryStream, 'OpenOrCreate','ReadWrite') + Add-Member NoteProperty MemoryStream $memoryStream -Force -InputObject $currentPackage + } + + $currentPackage + } + + filter packFile { + # If we are creating a package from a file + $resolvedItem = $_ + + $peekMagicBytes = Get-Content -AsByteStream -LiteralPath $resolvedItem.FullName -First 5 + + if ($peekMagicBytes[0,1] -as 'char[]' -join '' -eq 'PK') { + return $resolvedItem.FullName | packZip + } + elseif ( + $peekMagicBytes[0,1] -as 'char[]' -join '' -eq './' + ) { + return $resolvedItem | packTar + } + elseif ($peekMagicBytes[0] -eq 31 -and + $peekMagicBytes[1] -eq 139 -and + $peekMagicBytes[2] -eq 8 + ) { + return $resolvedItem | packTar + } + + # Read the file content + $fileBytes = Get-Content -AsByteStream -Raw -LiteralPath $resolvedItem.FullName + + # Get our current package + $currentPackage = getCurrentPack + + # Make the file name an encoded URI + $relativeUri = [Web.HttpUtility]::UrlEncode($resolvedItem.Name) + $relativeUri = '/' + ($relativeUri -replace '^/') + # $relativeUri = $relativeUri -replace '\s', '%20' + + # If the package has no identifier, + if (-not $currentPackage.PackageProperties.Identifier) { + # set it to the file name + $currentPackage.PackageProperties.Identifier = $resolvedItem.Name + } + + $currentPackage.PackageProperties.Identifier = $resolvedItem.Name + + # And determine the right extension content type + + $fileContentType = $typeMap[$resolvedItem.Extension] + + # and create a part + $newPart = $currentPackage.CreatePart($relativeUri, $fileContentType, $CompressionOption) + if (-not $newPart) { + continue + } + # then write the file to the part + $newStream = $newPart.GetStream() + $newStream.Write($fileBytes, 0, $fileBytes.Length) + $newStream.Close() + + # and set the package properties based off of the resolved info. + $currentPackage.PackageProperties.Created = $resolvedItem.CreationTime + $currentPackage.PackageProperties.Modified = $resolvedItem.LastWriteTime + $currentPackage + } + + filter packTar { + $namedParameters['TarFile'] = $_ + InvokeOpMethod 'Tar' $NamedParameters + } + + filter packZip { + $namedParameters['ZipFile'] = $_ + InvokeOpMethod 'Zip' $NamedParameters + } + + $generateEvent = [Runspace]::DefaultRunspace.Events.GenerateEvent + } + + process { + $namedParameters = [Ordered]@{} + $PSBoundParameters + if ($PSCmdlet.PagingParameters.First -lt 1mb) { + $namedParameters['First'] = $psCmdlet.PagingParameters.First + } + if ($PSCmdlet.PagingParameters.Skip -lt 1mb) { + $namedParameters['Skip'] = $psCmdlet.PagingParameters.Skip + } + $messageData = [Ordered]@{} + $namedParameters + # Generate an event + $getOpenPackageEvent = $generateEvent.Invoke( + 'Get-OpenPackage', # for Get-OpenPackage + $MyInvocation.MyCommand, # sent by this command + @( + # containing MyInvocation and MessageData + $MyInvocation, $messageData + ), + # And sending the message data dictionary along + $messageData, + # process in the current thread + $true, + # and wait for completion. + $true + ) + + # If the event was processed, and they said any form of "no" + if ($getOpenPackageEvent.MessageData.Rejected -or + $getOpenPackageEvent.MessageData.Reject -or + $getOpenPackageEvent.MessageData.No -or + $getOpenPackageEvent.MessageData.Deny + ) { + Write-Warning "Will not $($MyInvocation.Line)" + return + } + + if ($InputObject) { + $namedParameters.Remove('InputObject') + switch ($InputObject) { + {$_ -is [Management.Automation.PSModuleInfo] -and $_.Path} { + & $myCommandName -Module $InputObject @namedParameters + return + } + {$_ -is [IO.FileInfo] -or $_ -is [IO.DirectoryInfo]} { + & $myCommandName -FilePath $InputObject.FullName @namedParameters + return + } + {$_ -is [Collections.IDictionary]} { + & $myCommandName -Dictionary $InputObject @namedParameters + } + {$_ -is [uri]} { + & $myCommandName -Uri $InputObject @namedParameters + } + } + $namedParameters.InputObject = $InputObject + } + + # If arguments were provided, we are trying to be as natural with syntax as we can. + # Each string can map to a parameter, or to a verb of a related command to run. + if ($ArgumentList) { + $packages = @() + $loadedModules = @(Get-Module) + $outputPackages = $true + $namedParameters.Remove('ArgumentList') + + # Walk over each argument + :nextArgument for ($argNumber = 0; $argNumber -lt $ArgumentList.Length; $argNumber++) { + # If we have already output a package + if ($packages -and + # but it was not explicitly mapped + -not $namedParameters.'package' -and + $packages[0] -is [IO.Packaging.Package] + ) { + # map the package to the named parameters. + $namedParameters.'package' = $packages[0] + } + + $arg = $ArgumentList[$argNumber] + if ($arg -is [IO.Packaging.Package]) { + if ($packages -notcontains $arg) { + $packages += $arg + } + continue nextArgument + } + # If it is a string, path info, or uri + if ($arg -is [Management.Automation.PSModuleInfo]) { + $modulePackage = Get-OpenPackage -Module $arg @namedParameters + if ($packages -notcontains $modulePackage) { + $packages += $modulePackage + } + # and continue to the next argument. + continue nextArgument + } + + if ($arg -is [Collections.IDictionary]) { + $packages += & $myCommandName -Dictionary $arg @namedParameters + continue nextArgument + } + + if ($arg -is [string] -or + $arg -is [Management.Automation.PathInfo] -or + $arg -is [uri] + ) { + # see if the path exists + $slashKey = $arg -replace '^\.?/?', '/' -replace '/$' + + # If the input was a package, and it exists in the package + if ( + $InputObject -is [IO.Packaging.Package] -and + $InputObject.PartExists($slashKey) + ) { + # read the contents of the part and emit them to output + Get-OpenPackage -Uri $slashKey @namedParameters -InputObject $InputObject + # and continue to the next argument. + continue nextArgument + } + # If the argument is a path + if (Test-Path $arg -ErrorAction Ignore) { + # turn it into a package. + $filePackage = Get-OpenPackage -FilePath $arg @namedParameters + if ($packages -notcontains $filePackage) { + $packages += $filePackage + } + + continue nextArgument + } + # If the argument started with `@` + if ($arg -match '^@') { + # treat it as open-ended at syntax + $atPackages = Get-OpenPackage -At $arg @namedParameters + foreach ($atPackage in $atPackages) { + if ($atPackage -isnot [IO.Packaging.Package]) { + continue + } + if ($packages -notcontains $atPackage) { + $packages += $atPackage + } + } + continue nextArgument + } + # If the argument started with `at://` + if ($arg -match '^at://') { + # treat it as an at uri and turn it into a package. + $atPackage = Get-OpenPackage -AtUri $arg @namedParameters + if ($packages -notcontains $atPackage) { + $packages += $atPackage + } + continue nextArgument + } + # If the argument could be a URI + $argUri = $arg -as [uri] + # and that URI is absolute + if ($argUri.IsAbsoluteUri) { + # get that uri as a package + $uriPackage = Get-OpenPackage -Uri $argUri @namedParameters + if ($packages -notcontains $uriPackage) { + $packages += $uriPackage + } + continue nextArgument + } + + # The the argument is a string and the name of a loaded module + if ($arg -is [string] -and + $loadedModules.Name -contains $arg) { + $packages += Get-OpenPackage -Module $arg @namedParameters + continue nextArgument + } + + continue nextArgument + } + } + + # If we did not run any additional commands, + if ($outputPackages) { + $packages # we want to output our packages now + } + + return # and return. + } + + if ($inputObject -is [IO.Packaging.Package]) { + $namedParameters['Package'] = $InputObject + } + + if ($Installed) { + if (-not $env:OpenPackagePath) { + Write-Error '$env:OpenPackagePath not defined' + return + } + + Get-ChildItem -Path ($env:OpenPackagePath -split $( + if ($isLinux -or $IsMacOs) { + ':' + } else { + ';' + } + )) -ErrorAction Ignore + return + } + + if ($running) { + Get-Job | + Where-Object { + $job = $_ + if ($job.JobStateInfo.State -ne 'Running') { + return $false + } + + foreach ($pack in $job.Package) { + if ($pack -is [IO.Packaging.Package]) { + return $true + } + } + } + return + } + + # If we are passed a uri + if ($Uri) { + # pack it up + $namedParameters['Url'] = $uri + $namedParameters.Remove('Uri') + InvokeOpMethod 'Url' $NamedParameters + return + } + + #region Open Package in At Syntax + if ($At) { + $NamedParameters['At'] = $At + InvokeOpMethod 'At' $NamedParameters + return + } + #endregion Open Package in At Syntax + + #region Open Package from Repository + if ($Repository) { + $NamedParameters['Repository'] = $Repository + InvokeOpMethod 'Repository' $NamedParameters + return + } + #endregion Open Package from Repository + + #region Open Package from At Uri + if ($AtUri) { + $NamedParameters['AtUri'] = $AtUri + InvokeOpMethod 'AtProtocol' $NamedParameters + return + } + #endregion Open Package From At Uri + + if ($Module) { + if ($module -isnot [Management.Automation.PSModuleInfo]) { + $loadedModules = @(Get-Module) + foreach ($moduleInfo in $loadedModules) { + if ($moduleInfo.Name -eq $module) { + $module = $moduleInfo + break + } + } + } + if ($module -is [Management.Automation.PSModuleInfo] -and + $module.Path) { + $namedParameters.Remove('Module') + $namedParameters.FilePath = $module.Path | Split-Path + & $myCommandName @namedParameters + return + } + } + + if ($Dictionary) { + $namedParameters['DictionaryList'] = $_ + InvokeOpMethod 'Dictionary' $NamedParameters + return + } + + #region Open Package from Nuget + if ($Nuget) { + # Try to call our GetNuget method, and pass the `$Nuget` + InvokeOpMethod 'Nuget' $PSBoundParameters + return + } + #endregion Open Package from Nuget + + #region Open Package from Node + if ($NodePackage) { + # Try to call our node method + InvokeOpMethod 'Node' $PSBoundParameters + return + } + #endregion Open Package from Node + + #region Open Package from Python + if ($PythonPackage) { + # Try to call our Python method + InvokeOpMethod 'Python' $PSBoundParameters + return + } + #endregion Open Package from Python + + if ($filePath) { + $namedParameters.Remove('FilePath') + # Try to resolve the file path + $resolvedPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($FilePath) + # If we could not resolve the path, exit + if (-not $resolvedPath ) { return } + + # Get each file beneath the path + foreach ($resolved in $resolvedPath) { + # (watch our for escaped characters and hidden files) + $resolvedItem = Get-Item -LiteralPath ($resolved -replace '`') -Force:$IncludeHidden + + # If we could not resolve the item, continue + if (-not $resolvedItem) { continue } + + # If the item is a file + if ($resolvedItem -is [IO.FileInfo]) { + # make packages from the file + $resolvedItem | packFile + } + # If the item is a directory + elseif ($resolvedItem -is [IO.DirectoryInfo]) { + # We want a package from a directory + # Push into that location, for it will make operations easier + Push-Location -LiteralPath $resolvedItem.FullName + # Get all files beneath this point + $namedParameters['Directory'] = $resolvedItem.FullName + InvokeOpMethod 'Directory' $NamedParameters + Pop-Location + # make packages from the directory + } + } + + return + } + + getCurrentPack + } +} \ No newline at end of file diff --git a/Commands/Install-OpenPackage.ps1 b/Commands/Install-OpenPackage.ps1 new file mode 100644 index 0000000..dfa6634 --- /dev/null +++ b/Commands/Install-OpenPackage.ps1 @@ -0,0 +1,345 @@ +function Install-OpenPackage +{ + <# + .SYNOPSIS + Installs an OpenPackage + .DESCRIPTION + Installs an OpenPackage into a destination on disk. + .NOTES + Will also install a `.zip` and `.op` of the package to the parent directory of the installation + .LINK + Expand-Archive + #> + [CmdletBinding(SupportsShouldProcess,PositionalBinding=$false)] + [Alias( + 'Install-OP', 'inop', 'inOpenPackage', + 'Expand-OpenPackage','Expand-OP', 'enop','enOpenPackage' + )] + param( + # The arguments to Get-OpenPackage. + [Parameter(ValueFromRemainingArguments)] + [Alias('Arguments','Args','At','Url', 'AtUri', 'FilePath','Repository','Nuget')] + [PSObject[]] + $ArgumentList, + + <# + + The destination path. + + If provided, this should be a directory, but can be a file. + + If multiple packages will be installed and a -DestinationPath was provided, + all packages will be installed into that destination path. + + If no destination path is provided, + only packages with an identifier will be installed. + + Packages will install beneath the first `$env:OpenPackagePath`. + + If the package has a version, it will install into a versioned subdirectory. + + #> + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] + $DestinationPath, + + <# + Includes the specified parts. + + Enter a wildcard pattern, such as `*.txt` + #> + [ValidateNotNullOrEmpty()] + [SupportsWildcards()] + [string[]] + $Include, + + <# + Excludes the specified parts. + + Enter a wildcard pattern, such as `*.txt` + #> + [ValidateNotNullOrEmpty()] + [SupportsWildcards()] + [string[]] + $Exclude, + + <# + Includes the specified content types. + + Enter a wildcard pattern, such as `text/*` + #> + [ValidateNotNullOrEmpty()] + [SupportsWildcards()] + [string[]] + $IncludeContentType, + + <# + Excludes the specified content types. + + Enter a wildcard pattern, such as `text/*` + #> + [ValidateNotNullOrEmpty()] + [SupportsWildcards()] + [string[]] + $ExcludeContentType, + + # The input object. If this is not a package, it will be passed thru. + [Parameter(ValueFromPipeline)] + [Alias('Package')] + [PSObject] + $InputObject, + + # If set, will overwrite existing files. + [switch] + $Force, + + # If set, will clear the destination directory before installing. + [Alias('Clean')] + [switch] + $Clear, + + # If set, will output the files that are expanded from the package. + [switch] + $PassThru + ) + + # We want to collect all piped input first, + # so we can use the implicit `end` block. + # And collect all the piped input by enumerating `$input`. + $allInput = @($input) + + # We will be using Select-OpenPackage for filtering, so get a reference to it now. + $selectOpenPackage = $ExecutionContext.SessionState.InvokeCommand.GetCommand('Select-OpenPackage','Function') + + # Get our packages + # Each server can have any number of packages + # The order packages are defined is the order they are resolved + # This allows us to have any number of layers, in any order we want. + $packages = @( + # First up, lets process our input objects + # (piped in objects come first) + $remainingInput = @() + foreach ($in in $allInput) { + # Anything that is a package works + if ($in -is [IO.Packaging.Package]) { + $in + } + # so does anything that has a .Package property + elseif ( + $in.Package -is [IO.Packaging.Package] + ) { + $in.Package + } + # anything else we will pipe to Get-OpenPackage + else + { + $remainingInput += $in + } + } + # Now lets check a bound -InputObject + # If piped in, this will potentially be a duplicated + # (because `$InputObject` will contain the last bound value) + foreach ($in in $InputObject) { + # Skip any input we already have + if ($allInput -contains $in) { + continue + } + # If the -InputObject was a package + if ($in -is [IO.Packaging.Package]) { + $in # this works + } + # Otherwise, if the -InputObject has a .Package + elseif ( + $in.Package -is [IO.Packaging.Package] -and + # and it is not a package we already have collected + ($allInput.Package -notcontains $in.Package) + ) { + # then .Package works. + $in.Package + } + # Otherwise, we will pipe remaining input to Get-OpenPackage + elseif ($remainingInput -notcontains $in) { + $remainingInput += $in + } + } + # If there was remaining input + if ($remainingInput) { + # pipe it to Get-OpenPackage + $remainingInput | Get-OpenPackage @ArgumentList + } + # If we had arguments, + elseif ($ArgumentList) { + # call Get-OpenPackage. + Get-OpenPackage @ArgumentList + } + ) + + # Packages can only be installed once per execution of this function. + # So we will need to keep track of already installed packages + $alreadyInstalled = @() + + # We will also want to keep track of what we have cleared, so we can install layers. + $Cleared = @() + + # Keep track of any files we might overwrite + $existingFiles = @() + + # Go over each package we may have + foreach ($package in $packages) { + # skip anything that is not a package + if ($package -isnot [IO.Packaging.Package]) { + continue + } + # or that has already been installed + if ($alreadyInstalled -contains $package) { + continue + } + # If no DestinationPath was provided + if (-not $PSBoundParameters['DestinationPath']) { + # Check for $env:OpenPackagePath + if (-not $env:OpenPackagePath) { + # error out if missing. + Write-Error '$env:OpenPackagePath not defined' + return + } + # If there is no identifier + if (-not $package.Identifier) { + # error out + Write-Error "Must provide -DestinationPath or have a package identifier" + return + } + + # Set the destionation path + $PSBoundParameters['DestinationPath'] = $destinationPath = + Join-Path ( + # based off of the $env:OpenPackagePath + @($env:OpenPackagePath -split $( + if (-not ($IsLinux -or $IsMacOS)) { ';' } + else { ':' } + ))[0] + ) $package.Identifier # and the identifier + + # If the package had a version + if ($package.Version) { + # put it within the versioned directory + $PSBoundParameters['DestinationPath'] = $destinationPath = + Join-Path $DestinationPath $package.Version + } + } + + # Copy our parameters to Select-OpenPackage + $selectSplat = [Ordered]@{InputObject=$package} + foreach ($key in $PSBoundParameters.Keys) { + if ($selectOpenPackage.Parameters[$key]) { + $selectSplat[$key] = $PSBoundParameters[$key] + } + } + + # Get all of the package parts + $inputParts = @(Select-OpenPackage @selectSplat) + + # Now let's prepare our progress bars + $total = $inputParts.Length + $counter = 0 + $Progress = [Ordered]@{ + Activity = "Expanding $($package.PackageProperties.Identifier)" + Id = Get-Random + } + + + # If the destination path exist and has not been cleared + if ( + (Test-Path $DestinationPath) -and + $Cleared -notcontains $DestinationPath + ) { + # Clear it if we want to + if ($Clear -and $psCmdlet.ShouldProcess("Clear $destinationPath")) { + Remove-Item -ErrorAction Ignore -Path $DestinationPath -Recurse -Force:$Clear + } else { + # and warn if we do not. + Write-Warning "$DestinationPath exists. Use -Clear to clear the directory." + } + # Add it to the cleared directories either way, so we do not over warn. + $cleared += $DestinationPath + } + + # Go over each part + :nextPart foreach ($part in $inputParts) { + # Find their destination on disk + $partDestination = Join-Path $DestinationPath ([Web.HttpUtility]::UrlDecode($part.Uri)) + $counter++ + $Progress.Status = $part.Uri + $Progress.PercentComplete = $counter * 100 / $total + # and write progress. + Write-Progress @Progress + # Then check if it exists. + $fileInfo = + if ((Test-Path -LiteralPath $partDestination)) { + if (-not $Force) { + # We will warn when we're done, + # but don't -Force the point by warning each time. + # Add it to the list of existing files + $existingFile = [IO.FileInfo]"$partDestination" + if ($existingFile) { + $existingFiles += $existingFile + if ($passThru) { + $PSCmdlet.WriteObject($existingFiles[-1]) + } + } + continue nextPart # (and continue to the next part). + } + New-Item -ItemType File -Path $partDestination -Force + } else { + # create a file if it did not exist. + New-Item -ItemType File -Path $partDestination -Force + } + + # If we do not have a file, + if ($fileInfo -isnot [IO.FileInfo]) { + # continue to the next part + continue nextPart + } + + # Open the file for write + $fileStream = $fileInfo.OpenWrite() + # and continue if that did not work for any reason (for example, the file being locked) + if (-not $?) { continue nextPart } + # Get the part stream + $partStream = $part.GetStream() + # copy it to the file + $partStream.CopyTo($fileStream) + # and close and dispose of them both + $fileStream.Close() + $fileStream.Dispose() + + $partStream.Close() + $partStream.Dispose() + + # If we are passing thru + if ($PassThru) { + # get the exported files. + Get-Item -LiteralPath $fileInfo.FullName | + Add-Member NoteProperty Package $InputObject -Force -PassThru | + Add-Member NoteProperty PartUri $part.Uri -Force -PassThru | + Add-Member NoteProperty PartContentType $part.ContentType -Force -PassThru + } + } + + # After we have expanded all of the parts + $Progress.Remove('PercentComplete') + $Progress.Completed = $true + # complete our progress + Write-Progress @Progress + + # Mark this package as installed + $alreadyInstalled += $package + } + + if ($existingFiles) { + Write-Warning "$($existingFiles.Length) Files Exist (Use ``-Force`` to overwrite):$( + [Environment]::NewLine + $existingFiles -join [Environment]::NewLine + )" + } +} \ No newline at end of file diff --git a/Commands/Join-OpenPackage.ps1 b/Commands/Join-OpenPackage.ps1 new file mode 100644 index 0000000..eb99d4e --- /dev/null +++ b/Commands/Join-OpenPackage.ps1 @@ -0,0 +1,94 @@ +function Join-OpenPackage +{ + <# + .SYNOPSIS + Joins Open Packages + .DESCRIPTION + Joins multiple open packages into a single open package + #> + [Alias('Join-OP','jop','jOpenPackage')] + [CmdletBinding(PositionalBinding=$false)] + param( + [Parameter(ValueFromPipeline)] + [PSObject] + $InputObject, + + <# + Includes the specified parts. + + Enter a wildcard pattern, such as `*.txt` + + Wildcards are permitted. + #> + [ValidateNotNullOrEmpty()] + [SupportsWildcards()] + [string[]] + $Include, + + <# + Excludes the specified parts. + + Enter a wildcard pattern, such as `*.txt` + + Wildcards are permitted. + #> + [ValidateNotNullOrEmpty()] + [SupportsWildcards()] + [string[]] + $Exclude, + + <# + Includes the specified content types. + + Enter a wildcard pattern, such as `text/*` + #> + [ValidateNotNullOrEmpty()] + [SupportsWildcards()] + [string[]] + $IncludeContentType, + + <# + Excludes the specified content types. + + Enter a wildcard pattern, such as `text/*` + #> + [ValidateNotNullOrEmpty()] + [SupportsWildcards()] + [string[]] + $ExcludeContentType, + + [switch] + $Force + ) + + begin { + $memoryStream = [IO.MemoryStream]::new() + $combinedPackage = [IO.Packaging.Package]::Open($memoryStream, 'OpenOrCreate', 'ReadWrite') + $copySplat = [Ordered]@{Destination=$combinedPackage} + $copyOpenPackage = $ExecutionContext.SessionState.InvokeCommand.GetCommand('Copy-OpenPackage','Function') + foreach ($key in $psBoundParameters.Keys) { + if ($copyOpenPackage.Parameters[$key]) { + $copySplat[$key] = $PSBoundParameters[$key] + } + } + + $allIdentifiers = @() + } + + process { + if ($InputObject -isnot [IO.Packaging.Package]) { + return $InputObject + } + $allIdentifiers += $InputObject.Identifier + + $combinedPackage = $InputObject | Copy-OpenPackage @copySplat + } + + end { + $allUniqueIdentifiers = @($allIdentifiers | Select-Object -Unique) + + $combinedPackage.Identifier = $combinedPackage.Identifier -replace '\.[^\.]+?$' + + $combinedPackage + } +} \ No newline at end of file diff --git a/Commands/Lock-OpenPackage.ps1 b/Commands/Lock-OpenPackage.ps1 new file mode 100644 index 0000000..eb0c1d7 --- /dev/null +++ b/Commands/Lock-OpenPackage.ps1 @@ -0,0 +1,65 @@ +function Lock-OpenPackage +{ + <# + .SYNOPSIS + Locks an Open Package + .DESCRIPTION + Locks an Open Package. + + Closes the package and copies it into a read-only package. + .NOTES + This helps prevent any drift in the package and limit access in server scenarios. + .EXAMPLE + # Import OP, make it a package, and lock it + Import-Module OP -PassThru | + Get-OpenPackage | + Lock-OpenPackage + .EXAMPLE + # Import OP, make it a package, and lock it + impo OP -PassThru | op | lkop + #> + [CmdletBinding(ConfirmImpact='Medium')] + [Alias('Lock-OP', 'lkop','lkOpenPackage')] + param( + # The input object. This should be a package. + [Parameter(ValueFromPipeline,ValueFromPipelineByPropertyName)] + [Alias('Package')] + [PSObject] + $InputObject + ) + + process { + # If the input is not a package + if ($InputObject -isnot [IO.Packaging.Package]) { + return $InputObject # pass it thru. + } + + # If the file access is read, we're already locked + if ($inputObject.FileOpenAccess -eq 'Read') { + return $inputObject # so return the current object. + } + + # If the input has no memory stream + if ($InputObject.MemoryStream -isnot [IO.MemoryStream]) { + return $InputObject # pass it thru + } + + # To read the stream we need to close the package. + $InputObject.Close() # this also makes the current package useless + # Then we have to seek to the start of the stream + $null = $InputObject.MemoryStream.Seek(0,'begin') + # and create a new stream from the old. + $newStream = [IO.MemoryStream]::new( + $InputObject.MemoryStream.ToArray() + ) + # Then close and dispose of the memory stream. + $InputObject.MemoryStream.Close() + $InputObject.MemoryStream.Dispose() + + # Create a new package from our new stream, as a read only package. + $newPackage = [IO.Packaging.Package]::Open($newStream, 'Open', 'Read') + $newPackage | Add-Member NoteProperty MemoryStream $newStream -Force + + $newPackage + } +} \ No newline at end of file diff --git a/Commands/New-OpenPackage.ps1 b/Commands/New-OpenPackage.ps1 new file mode 100644 index 0000000..3273b72 --- /dev/null +++ b/Commands/New-OpenPackage.ps1 @@ -0,0 +1,20 @@ +function New-OpenPackage { + <# + .SYNOPSIS + Creates an empty open package + .DESCRIPTION + Creates an new empty open package + #> + [Alias('New-OP','nop', 'nOpenPackage')] + param() + + process { + $memoryStream = [IO.MemoryStream]::new() + $package = [IO.Packaging.Package]::Open($memoryStream, 'OpenOrCreate') + $package | + Add-Member NoteProperty MemoryStream $memoryStream -Force -PassThru + } + + + +} \ No newline at end of file diff --git a/Commands/Publish-OpenPackage.ps1 b/Commands/Publish-OpenPackage.ps1 new file mode 100644 index 0000000..c3446f1 --- /dev/null +++ b/Commands/Publish-OpenPackage.ps1 @@ -0,0 +1,173 @@ +function Publish-OpenPackage { + <# + .SYNOPSIS + Publishes Open Package + .DESCRIPTION + Publishes Open Packages using any `OpenPackage.Publisher` or command. + #> + [CmdletBinding(PositionalBinding=$false,SupportsShouldProcess)] + [Alias('Publish-OP', 'pbop', 'pbOpenPackage')] + param( + # The name of the Publisher, or a command or script block used to Publish. + # One or more publishers may be provided. They will be processed in the order provided. + [Parameter(Mandatory,Position=0)] + [ArgumentCompleter({ + param ( $commandName, + $parameterName, + $wordToComplete, + $commandAst, + $fakeBoundParameters ) + $typeData = Get-TypeData -TypeName OpenPackage.Publisher + + if (-not $wordToComplete) { + $typeData.Members.Keys + } else { + $typeData.Members.Keys -match ([Regex]::Escape($wordTocomplete)) + } + })] + [PSObject[]] + $Publisher, + + # The Any Positional arguments for the view + [Parameter(Position=1,ValueFromRemainingArguments)] + [Alias('Argument','Arguments','Args')] + [PSObject[]] + $ArgumentList, + + # Any input objects. + [Parameter(ValueFromPipeline)] + [Alias('Package')] + [PSObject[]] + $InputObject, + + # Any options to pass to the View + [Alias('Options','Parameter','Parameters')] + [Collections.IDictionary] + $Option = [Ordered]@{} + ) + + # Gather all input + $allInput = @($input) + + if (-not $allInput -and $InputObject) { + $allInput += $InputObject + } + + # Get the typedata that describes Publishers + $typeData = Get-TypeData -TypeName OpenPackage.Publisher + + # Define a filter to run each publisher + filter publish { + $Publisher = $_ + # If the Publisher is a string + if ($Publisher -is [string]) { + # check for a Publisher with that name + if ($typeData.Members[$Publisher].Script) { + # if one is found, call it with the package and option + $Publisher = $typeData.Members[$Publisher].Script + } + elseif ( + $typeData.Members[$publisher].ReferencedMemberName -and + $typeData.Members[ + "$($typeData.Members[$publisher].ReferencedMemberName)" + ].Script + ) { + $Publisher = $typeData.Members[ + "$($typeData.Members[$publisher].ReferencedMemberName)" + ].Script + } + else { + # Othewise, try to find a Publisher command + $PublisherCommand = + $ExecutionContext.SessionState.InvokeCommand.GetCommand( + $Publisher,'Cmdlet,Alias,Function' + ) + # If we found one, try to use it + if ($PublisherCommand) { + $Publisher = $PublisherCommand + } else { + # Otherwise, warn that Publisher is unknown + Write-Warning "Unknown Publisher $Publisher" + # and break out of the loop. + return + } + } + } + + # If this publisher is not a script block or command + if ($Publisher -isnot [ScriptBlock] -and + $Publisher -isnot [Management.Automation.CommandInfo] + ) { + # write an error and return + Write-Error "Publisher must be a name of a OpenPackage.Publisher method, ScriptBlock, or Command" + return + } + + # Get our command metadata. + $commandMetaData = + if ($Publisher -is [ScriptBlock]) { + # If the Publisher is a scriptblock + # make a temporary function + $function:Publisher = $Publisher + # get its metadata + $ExecutionContext.SessionState.InvokeCommand.GetCommand('Publisher', 'Function') -as + [Management.Automation.CommandMetaData] + # and remove the temporary function + Remove-Item 'function:Publisher' + } else { + # otherwise, just cast to command metadata + $commandName = $Publisher.Name + Publisher -as [Management.Automation.CommandMetaData] + } + + # Once we have commandmetadata, we can find parameter names. + $validParameterNames = @( + $commandMetaData.Parameters.Keys + $commandMetaData.Parameters.Values.Aliases + if ($commandMetaData.SupportsShouldProcess) { + 'WhatIf' + 'Confirm' + } + ) + + # We want to be forgiving with input, so copy the options + $commandParameters = [Ordered]@{} + $Option + if ($WhatIfPreference) { $commandParameters.WhatIf = $true} + if ($PSBoundParameters['Confirm']) { + $commandParameters.Confirm = $PSBoundParameters['Confirm'] + } + # Check each key + foreach ($key in @($commandParameters.Keys)) { + # If we have valid parameter names + if ($validParameterNames -and + # and this isn't one of them, + $validParameterNames -notcontains $key + ) { + # write a warning + Write-Warning "Option $key not supported by $($commandName)" + # and remove the key. + $commandParameters.Remove($key) + } + } + + # If there was any input, pipe it into the publisher. + if ($allInput) { + # If there were arguments, pass them + if ($ArgumentList) { + $allInput | & $Publisher @ArgumentList @commandParameters + } else { + $allInput | & $Publisher @commandParameters + } + } else { + if ($ArgumentList) { + & $Publisher @ArgumentList @commandParameters + } else { + & $Publisher @commandParameters + } + } + } + + # Collect all the publishers + @($Publisher) | + publish # and publish them. +} \ No newline at end of file diff --git a/Commands/Read-OpenPackage.ps1 b/Commands/Read-OpenPackage.ps1 new file mode 100644 index 0000000..a60edaf --- /dev/null +++ b/Commands/Read-OpenPackage.ps1 @@ -0,0 +1,138 @@ +function Read-OpenPackage +{ + <# + .SYNOPSIS + Reads Open Package Bytes + .DESCRIPTION + Reads Bytes within an Open Package + .LINK + Get-OpenPackage + .LINK + Write-OpenPackage + .EXAMPLE + $package = OP @{"hello.txt" = "Hello world"} + $package | + Read-OpenPackage -Uri /hello.txt + .EXAMPLE + $package = OP @{"hello.txt" = "Hello world"} + ($package | + Read-OpenPackage -Uri /hello.txt -RangeStart 0 -RangeEnd 5) -as 'char[]' + .EXAMPLE + $package = OP @{"hello.txt" = "Hello world"} + ($package | + Read-OpenPackage -Uri /hello.txt -RangeStart 0 -RangeEnd 5) -as 'char[]' -join '' + #> + [OutputType([byte[]])] + [Alias('Read-OP', 'rdop', 'rdOpenPackage')] + param( + # One or more part uris + [Parameter(Mandatory,ValueFromPipelineByPropertyName)] + [Alias('PartUri')] + [Uri[]] + $Uri, + + # A start range + [Parameter(ValueFromPipelineByPropertyName)] + [Alias('PartStart')] + [long] + $RangeStart, + + # An ending range + [Parameter(ValueFromPipelineByPropertyName)] + [Alias('PartEnd')] + [long] + $RangeEnd, + + # The input object. + # If this is not a package, it will be passed thru. + [Parameter(ValueFromPipeline)] + [Alias('Package')] + [PSObject] + $InputObject + ) + + process { + # If the input was not a package, + if ($InputObject -isnot [IO.Packaging.Package]) { + return $InputObject # pass it thru. + } + + # Read each part in the open package. + foreach ($partUri in $uri) { + # Make sure we have a leading slash + $SlashPart = "$partUri" -replace '^/?', '/' + + # If the part does not exist + if (-not $InputObject.PartExists($SlashPart)) { + # continue to the next part. + continue + } + + # Try to get the part + $part = $InputObject.GetPart($SlashPart) + # if that failed, continue. + if (-not $part) { + continue + } + + # If we do not have a range start and end + if (-not $RangeStart -and -not $RangeEnd) { + # look for a reader + if ($part.Reader -and $part.Read) { + # If we found one, + $part.Read() # read the content + continue # and continue. + } + # Otherwise, get our stream + $partStream = $part.GetStream('Open', 'Read') + # copy the output to a memory stream + $memoryStream = [IO.MemoryStream]::new() + $partStream.CopyTo($memoryStream) + } else { + # If a range was provided, get our stream + $partStream = $part.GetStream('Open', 'Read') + # If the range was smaller than the stream length + if ($RangeStart -lt $partStream.Length) { + # seek to the start of the range + $null = $partStream.Seek($RangeStart, 'Begin') + # and adjust the end if needed + if ($rangeEnd -ge $partStream.Length) { + $rangeEnd = $partStream.Length + } + # If the total range is positive + if ($RangeEnd - $RangeStart -gt 0) { + # get the range + $buffer = [byte[]]::new($RangeEnd - $RangeStart) + # read our byte range + $bytesRead = $partStream.Read($buffer, 0, $buffer.Length) + if ($bytesRead) { + $memoryStream = [IO.MemoryStream]::new() + # and write it to the memory stream + $null = $memoryStream.Write($buffer,0 , $bytesRead) + } + } + + } else { + # copy the output to a memory stream + $memoryStream = [IO.MemoryStream]::new() + $partStream.CopyTo($memoryStream) + } + } + + $partStream.Close() # Close our part stream + $partStream.Dispose() # and dispose of it. + + # If we had a memory stream + if ($memoryStream) { + # get it as an array + [byte[]]$partBytes = $memoryStream.ToArray() + + $memoryStream.Close() # then close + $memoryStream.Dispose() # and dispose our stream. + + # and write the byte[] as a single object. + $PSCmdlet.WriteObject($partBytes, $false) + } + } + } +} \ No newline at end of file diff --git a/Commands/Remove-OpenPackage.ps1 b/Commands/Remove-OpenPackage.ps1 new file mode 100644 index 0000000..e22d13a --- /dev/null +++ b/Commands/Remove-OpenPackage.ps1 @@ -0,0 +1,72 @@ +function Remove-OpenPackage { + <# + .SYNOPSIS + Removes parts from an open package + .DESCRIPTION + Removes content parts from an open package. + .LINK + Get-OpenPackage + .LINK + Select-OpenPackage + .EXAMPLE + Get-OpenPackage @{ + "a.html" = "

a html file

" + } | + Remove-OpenPackage -Uri '/a.html' + .EXAMPLE + Get-OpenPackage @{ + "a.html" = "

a html file

" + "a.css" = "body { max-width: 100vw; height: 100vh}" + } | + Select-OpenPackage -Include *.html | + Remove-OpenPackage + #> + [CmdletBinding(SupportsShouldProcess,ConfirmImpact='High')] + [Alias('Remove-OP','rop','rOpenPackage')] + param( + # One or more URIs to remove + [Parameter(ValueFromPipelineByPropertyName)] + [Uri[]] + $Uri, + + # The input object. + # If this is not a package, the input will be passed thru and nothing will be removed. + [Parameter(ValueFromPipeline,ValueFromPipelineByPropertyName)] + [Alias('Package')] + [PSObject] + $InputObject + ) + + process { + # If the input object is not a package, + if ($InputObject -isnot [IO.Packaging.Package]) { + # look for a property named package + if ($inputObject.Package -is [IO.Packaging.Package]) { + # and use that as our input. + $inputObject = $inputObject.Package + } else { + # Otherwise, pass thru the input. + return $InputObject + } + } + + foreach ($part in @($InputObject.GetParts())) { + # If -WhatIf was passed, return the part + if ($whatIfPreference) { + $part + continue + } + + # If the part uri is in the list of uris + if ($part.Uri -in $Uri -and + # and we confirmed our intention to delete it + $PSCmdlet.ShouldProcess("Remove $Uri") + ) { + # delete the part. + $InputObject.DeletePart($part.Uri) + } + } + + $InputObject + } +} \ No newline at end of file diff --git a/Commands/Select-OpenPackage.ps1 b/Commands/Select-OpenPackage.ps1 new file mode 100644 index 0000000..f29da2b --- /dev/null +++ b/Commands/Select-OpenPackage.ps1 @@ -0,0 +1,308 @@ +function Select-OpenPackage +{ + <# + .SYNOPSIS + Selects Open Package content + .DESCRIPTION + Selects content from an Open Package using Regular Expressions or XPath + .LINK + Select-String + .LINK + Select-Xml + #> + [Alias('Select-OP','scop', 'scOpenPackage')] + [CmdletBinding(DefaultParameterSetName='All')] + param( + # A list of patterns to match. + [Parameter(ParameterSetName='Select-String',ValueFromPipelineByPropertyName)] + [PSObject[]] + $Pattern, + + # Indicates that the cmdlet uses a simple match rather than a regular expression match. + [Parameter(ParameterSetName='Select-String')] + [switch] + $SimpleMatch, + + # Indicates that the cmdlet matches are case-sensitive. By default, pattern matches aren't case-sensitive. + [Parameter(ParameterSetName='Select-String')] + [switch] + $CaseSensitive, + + <# + Indicates that the cmdlet returns a simple response instead of a `[MatchInfo]` object. + The returned value is `$true` if the pattern is found or `$null` if the pattern is not found. + #> + [Parameter(ParameterSetName='Select-String')] + [switch] + $Quiet, + + # Only the first instance of matching text is returned from each input file. + # This is the most efficient way to retrieve a list of files that have contents matching the regular expression. + [Parameter(ParameterSetName='Select-String')] + [switch] + $List, + + <# + By default, `Select-String` highlights the string that matches the pattern you searched for with the + `-Pattern` parameter. The `-NoEmphasis` parameter disables the highlighting. + #> + [Parameter(ParameterSetName='Select-String')] + [switch] + $NoEmphasis, + + <# + Includes the specified parts. + + Enter a wildcard pattern, such as `*.txt` + + Wildcards are permitted. + #> + [ValidateNotNullOrEmpty()] + [SupportsWildcards()] + [string[]] + $Include, + + <# + Excludes the specified parts. + + Enter a wildcard pattern, such as `*.txt` + + Wildcards are permitted. + #> + [ValidateNotNullOrEmpty()] + [SupportsWildcards()] + [string[]] + $Exclude, + + <# + Includes the specified content types. + + Enter a wildcard pattern, such as `text/*` + #> + [ValidateNotNullOrEmpty()] + [SupportsWildcards()] + [string[]] + $IncludeContentType, + + <# + Excludes the specified content types. + + Enter a wildcard pattern, such as `text/*` + #> + [ValidateNotNullOrEmpty()] + [SupportsWildcards()] + [string[]] + $ExcludeContentType, + + # The `-NotMatch` parameter finds text that doesn't match the specified pattern. + [Parameter(ParameterSetName='Select-String')] + [switch] + $NotMatch, + + <# + Indicates that the cmdlet searches for more than one match in each line of text. + Without this parameter, `Select-String` finds only the first match in each line of text. + + When `Select-String` finds more than one match in a line of text, it still emits only one + `[MatchInfo]` object for the line, but the `.Matches` property of the object contains all the + matches. + #> + [Parameter(ParameterSetName='Select-String')] + [switch] + $AllMatches, + + <# + + Captures the specified number of lines before and after the line that matches the pattern. + + If you enter one number as the value of this parameter, that number determines the number of lines + captured before and after the match. If you enter two numbers as the value, the first number + determines the number of lines before the match and the second number determines the number of lines + after the match. For example, `-Context 2,3`. + #> + [Parameter(ParameterSetName='Select-String')] + [ValidateNotNullOrEmpty()] + [ValidateCount(1, 2)] + [ValidateRange(0, 2147483647)] + [int[]] + $Context, + + <# + Causes the cmdlet to output only the matching strings, rather than **MatchInfo** objects. This is + the results in behavior that's the most similar to the Unix **grep** or Windows **findstr.exe** + commands. + #> + [Parameter(ParameterSetName='Select-String')] + [switch] + $Raw, + + # Specifies an XPath search query. The query language is case-sensitive. + [Parameter(Mandatory,ParameterSetName='Select-XML')] + [Parameter(ValueFromPipelineByPropertyName)] + [PSObject] + $XPath, + + <# + Specifies a hash table of the namespaces used in the XML. + + Use the format`@{ = }`. + + When the XML uses the default namespace, which begins with xmlns, use an arbitrary key for the + namespace name. You cannot use xmlns. In the XPath statement, prefix each node name with the + namespace name and a colon, such as `//namespaceName:Node`. + #> + [Parameter(ParameterSetName='Select-XML')] + [Parameter(ValueFromPipelineByPropertyName)] + [Collections.IDictionary] + $Namespace, + + # One or more Abstract Syntax Tree conditions. + # These will select elements in any PowerShell scripts that match the condition. + [Parameter(ParameterSetName='Ast', ValueFromPipelineByPropertyName)] + [Alias('AstSearch', 'SearchAst')] + [ScriptBlock[]] + $AstCondition, + + # The input object. This should be a package. + [Parameter(ValueFromPipeline,ValueFromPipelineByPropertyName)] + [Alias('Package')] + [PSObject] + $InputObject + ) + + begin { + $selectString = $ExecutionContext.SessionState.InvokeCommand.GetCommand('Select-String','Cmdlet') + $selectXml = $ExecutionContext.SessionState.InvokeCommand.GetCommand('Select-Xml','Cmdlet') + filter selectOrList { + if ($list) { + $inputPart + } else { + $_ + } + } + } + + process { + if ($InputObject -isnot [IO.Packaging.Package]) { + return $InputObject + } + + $inputParts = @($InputObject.GetParts()) + + $inputParts = @( + :nextPart foreach ($inputPart in $inputParts) { + if ($ExcludeContentType) { + foreach ($wildcard in $Exclude) { + if ($inputPart.ContentType -like $wildcard) { + continue nextPart + } + } + } + + if ($Exclude) { + foreach ($wildcard in $Exclude) { + if ($inputPart.Uri -like $wildcard) { + continue nextPart + } + } + } + + if ($include) { + $included = $false + :included foreach ($wildcard in $include) { + if ($inputPart.Uri -like $wildcard) { + $included = $true + break included + } + } + if (-not $included) { + continue nextPart + } + } + + if ($IncludeContentType) { + $included = $false + :included foreach ($wildcard in $include) { + if ($inputPart.ContentType -like $wildcard) { + $included = $true + break included + } + } + if (-not $included) { + continue nextPart + } + } + + $inputPart + } + ) + + :nextPart foreach ($inputPart in $inputParts) { + if (-not $Pattern -and -not $XPath -and -not $AstCondition) { + $inputPart + continue + } + $inputStream = $inputPart.GetStream() + $inputStreamReader = [IO.StreamReader]::new($inputStream) + + $inputText = $inputStreamReader.ReadToEnd() + + $inputStreamReader.Close() + $inputStreamReader.Dispose() + $inputStream.Close() + $inputStream.Dispose() + + $selectParameters = [Ordered]@{} + + $inputAsXml = $inputText -as [xml] + + if ($XPath -and $inputAsXml) { + $selectParameters.XPath = $XPath + if ($namespace) { + $selectParameters.Namespace = @{} + foreach ($key in $Namespace.Keys) { + $selectParameters.Namespace[$key] = $Namespace[$key] + } + } + $inputAsXml | + & $selectXml @selectParameters | + Add-Member NoteProperty Uri $inputPart.Uri -Force -PassThru | + Select-Object Node, Pattern, Uri | + . selectOrList + } + + if ($Pattern) { + foreach ($key in $PSBoundParameters.Keys) { + if ($selectString.Parameters[$key]) { + $selectParameters[$key] = $PSBoundParameters[$key] + } + } + $selectParameters.Remove('Include') + $selectParameters.Remove('Exclude') + + Select-String @selectParameters -InputObject $inputText | + Add-Member NoteProperty Uri $inputPart.Uri -Force -PassThru | + Add-Member NoteProperty Package $InputObject -Force -PassThru | + . selectOrList + } + + $inputAsScript = + if ($inputPart.Uri -match '\.ps[md]?1$') { + try { [scriptblock]::Create($inputText) } + catch { $null } + } else { + '' + } + + + if ($AstCondition -and $inputAsScript.Ast.FindAll) { + foreach ($searchScript in $AstCondition) { + $inputAsScript.Ast.FindAll($searchScript, $true) | + Add-Member NoteProperty Uri $inputPart.Uri -Force -PassThru | + Add-Member NoteProperty Package $InputObject -Force -PassThru | + . selectOrList + } + } + } + } +} \ No newline at end of file diff --git a/Commands/Set-OpenPackage.ps1 b/Commands/Set-OpenPackage.ps1 new file mode 100644 index 0000000..ab8aa7d --- /dev/null +++ b/Commands/Set-OpenPackage.ps1 @@ -0,0 +1,162 @@ +function Set-OpenPackage +{ + <# + .SYNOPSIS + Sets Open Package content + .DESCRIPTION + Sets content in an Open Packaging Conventions archive. + .EXAMPLE + $miniServer = Get-OpenPackage | + Set-OpenPackage -Uri '/index.html' -Content ([xml]"

Hello World

") -ContentType text/html | + Start-OpenPackage + Start-Process -FilePath $miniServer.Name + .LINK + Get-OpenPackage + #> + [Alias('Set-OP','sop','sOpenPackage')] + param( + # The uri to set + [Parameter(Mandatory,ValueFromPipelineByPropertyName)] + [Alias('Url','Href','PartUri','PartUrl','LocalPath')] + [uri] + $Uri, + + # The content to set. + [Parameter(ValueFromPipelineByPropertyName)] + [Alias('Buffer','Text')] + [PSObject] + $Content, + + # The content type. By default, `text/plain` + [Parameter(ValueFromPipelineByPropertyName)] + [string] + $ContentType, + + # The serialization depth. + [Parameter(ValueFromPipelineByPropertyName)] + [ValidateRange(1,100)] + [int] + $Depth = $( + if ($FormatEnumerationLimit * 2 -gt 1) { + $FormatEnumerationLimit * 2 + } else { + 4 + } + ), + + # The options used to write the content. + [Parameter(ValueFromPipelineByPropertyName)] + [Alias('Options')] + [Collections.IDictionary] + $Option = [Ordered]@{}, + + # The input object. + # This must be a package, and it must be writeable. + [Parameter(ValueFromPipeline)] + [Alias('Package')] + [PSObject] + $InputObject, + + # A content type map. + # This maps extensions and URIs to a content type. + [Collections.IDictionary] + $TypeMap = $( + ([PSCustomObject]@{PSTypeName='OpenPackage.ContentTypeMap'}).TypeMap + ), + + # Sets a part, even if it already exists. + [switch] + $Force + ) + + process { + # If there is no input, there is nothing to do + if (-not $InputObject) { return } + # If the input is not a package, pass it thru. + if ($InputObject -isnot [IO.Packaging.Package]) { + return $InputObject + } + + # If the uri is not prefixed, + if ($uri -notmatch '^/') { + $uri = "/$uri" # add it to avoid easy errors. + } + + # Create or recreate the part (otherwise writes might be partial) + $part = + if ($InputObject.PartExists($uri)) { + if (-not $Force) { + Write-Error "'$uri' already exists, use -Force to overwrite" + return + } + + $InputObject.GetPart($uri) + } else { + if (-not $PSBoundParameters['ContentType']) { + $extension = @($uri -split '\.' -ne '')[-1] + if ($typeMap.TypeMap.Contains($extension)) { + $contentType = $typeMap.TypeMap[$extension] + } else { + $contentType = 'text/plain' + } + } + $InputObject.CreatePart($uri, $ContentType) + } + + # If we don't have a part, return + if (-not $part) { return } + + # If we have a writer for the part: + if ($part.Writer -and $part.Write) { + # * Prepare the options + if (-not $option) { + $Option = [Ordered]@{} + } + # * Copy in depth + if (-not $option.Depth) { + $Option.Depth = $Depth + } + # * Call the writer + $part.Write($Content, $option) + # * and return. + return $InputObject + } + + # Get the stream + $partStream = $part.GetStream() + $partStream.SetLength(0) + # First see if the content is a byte[] + if ($content -is [byte[]]) { + # if so, just write it + $partStream.Write($content, 0, $content.Length) + } + # If the content is a stream, + elseif ($content -is [IO.Stream]) { + # copy it in. + $content.CopyTo($partStream) + } + # If the content was xml or could be, + elseif ($content -is [xml] -or ($contentXml = $content -as [xml])) { + if ($contentXml) { $content = $contentXml } + $content.Save($partStream) + } elseif ($content -is [string]) { + # Put strings in as a byte array. + $buffer = $OutputEncoding.GetBytes($content) + $partStream.Write($buffer, 0, $buffer.Length) + } elseif ($contentBytes = $content -as [byte[]]) { + # Bytes are obviously a byte array + $partStream.Write($contentBytes, 0, $contentBytes.Length) + } + else { + # and everything else is stringified + $buffer = $OutputEncoding.GetBytes("$content") + $partStream.Write($buffer, 0, $buffer.Length) + } + + # Close the part stream + $partStream.Close() + + # then pass it thru so we can keep piping. + $inputObject + } +} \ No newline at end of file diff --git a/Commands/Split-OpenPackage.ps1 b/Commands/Split-OpenPackage.ps1 new file mode 100644 index 0000000..d54963e --- /dev/null +++ b/Commands/Split-OpenPackage.ps1 @@ -0,0 +1,108 @@ +function Split-OpenPackage { + <# + .SYNOPSIS + Splits Open Packages + .DESCRIPTION + Splits Open Packages into multiple parts + .EXAMPLE + Get-Module OP | + OP | + Split-OpenPackage + .LINK + Group-Object + .LINK + Select-OpenPackage + #> + [CmdletBinding(PositionalBinding=$false)] + [Alias('Split-OP','slop', 'slOpenPackage')] + param( + # One or more properties to group. + [Parameter(ValueFromRemainingArguments)] + [PSObject[]] + $Property, + <# + Includes the specified parts. + + Enter a wildcard pattern, such as `*.txt` + + Wildcards are permitted. + #> + [ValidateNotNullOrEmpty()] + [SupportsWildcards()] + [string[]] + $Include, + + <# + Excludes the specified parts. + + Enter a wildcard pattern, such as `*.txt` + + Wildcards are permitted. + #> + [ValidateNotNullOrEmpty()] + [SupportsWildcards()] + [string[]] + $Exclude, + + <# + Includes the specified content types. + + Enter a wildcard pattern, such as `text/*` + #> + [ValidateNotNullOrEmpty()] + [SupportsWildcards()] + [string[]] + $IncludeContentType, + + <# + Excludes the specified content types. + + Enter a wildcard pattern, such as `text/*` + #> + [ValidateNotNullOrEmpty()] + [SupportsWildcards()] + [string[]] + $ExcludeContentType, + + [Parameter(ValueFromPipeline,ValueFromPipelineByPropertyName)] + [Alias('Package')] + [PSObject] + $InputObject + ) + + begin { + $selectOpenPackage = $ExecutionContext.SessionState.InvokeCommand.GetCommand('Select-OpenPackage','Function') + } + + process { + if ($InputObject -isnot [IO.Packaging.Package]) { + return $InputObject + } + $selectSplat = [Ordered]@{InputObject=$InputObject} + foreach ($key in $PSBoundParameters.Keys) { + if ($selectOpenPackage.Parameters[$key]) { + $selectSplat[$key] = $PSBoundParameters[$key] + } + } + $selectedParts = Select-OpenPackage @selectSplat + + if (-not $property) { + $Property = { @(@($_.Uri -split '/' -ne '')[-1] -split '\.')[-1] } + } + foreach ($group in $selectedParts | Group-Object -Property $Property) { + # If the group has no name, it will be excluded + if (-not $group.Name) { continue } + $splitPackage = $inputObject | + Copy-OpenPackage -Include $group.Group.Uri + + $splitPackage.Identifier = + $splitPackage.Identifier -replace + "(?:\p{P}$([Regex]::Escape($group.Name)))?$", ('.' + $group.Name) + + $splitPackage + } + } + end { + + } +} \ No newline at end of file diff --git a/Commands/Start-OpenPackage.ps1 b/Commands/Start-OpenPackage.ps1 new file mode 100644 index 0000000..51fa651 --- /dev/null +++ b/Commands/Start-OpenPackage.ps1 @@ -0,0 +1,763 @@ +function Start-OpenPackage { + <# + .SYNOPSIS + Starts a OpenPackage Server + .DESCRIPTION + Starts a server, using one or more archive packages as the storage. + .NOTES + If a URI in the package is requested, that URI will be returned. + + If a path does not have an extension, it will search for an .index.html. + + If the file was not found, a 404 code will be returned. + + If the package contains a `/404.html`, the content in this file will be returned with the 404 + + If another method than GET or HEAD is used, a 405 code will be returned. + + If the package contains a `/405.html`, then content in this file will be returned with the 405. + .LINK + Get-OpenPackage + #> + [Alias('Start-OP','stOpenPackage')] + [CmdletBinding(PositionalBinding=$false)] + param( + # The path to an Open Package file, or a glob that matches multiple Open Package files. + [Parameter(ValueFromRemainingArguments)] + [Alias('Arguments','Args','At','Url', 'AtUri', 'FilePath','Repository','Nuget')] + [PSObject[]] + $ArgumentList, + + # The root url. + # By default, this will be automatically to a random local port. + # If running elevated, can be any valid http listener prefix, including `http://*/` + [string] + $RootUrl = "http://127.0.0.1:$(Get-Random -Minimum 4200 -Maximum 42000)/", + + # The input object. This can be provided to avoid loading a file from disk. + [Parameter(ValueFromPipeline)] + [Alias('Package')] + [PSObject[]] + $InputObject, + + # The allowed http verbs. + [string[]] + $Allow = @('get', 'head'), + + # The content type map + [Collections.IDictionary] + $TypeMap = $( + ([PSCustomObject]@{PSTypeName='OpenPackage.ContentTypeMap'}).TypeMap + ), + + # A Route Table + [Collections.IDictionary] + $Route = [Ordered]@{}, + + # The throttle limit. + # This is the number of concurrent jobs that can be running at once. + [uint16]$ThrottleLimit = .5kb, + + # The buffer size. + # If parts are smaller than this size, they will be streamed. + # If parts are larger than this size, they will be handled in the background + # (and may use a buffer of this size when accepting range requests) + [uint]$BufferSize = 16mb, + + # The lifespan of the server. + # If provided, will automatically stop the server after it's life is over. + [TimeSpan]$Lifespan, + + # The number of nodes to run. + # Each node can handle incoming requests. + [byte]$NodeCount = 2 + ) + + begin { + # Requires Start-ThreadJob + $startThreadJob = $ExecutionContext.SessionState.InvokeCommand.GetCommand('Start-ThreadJob', 'Cmdlet,Function') + if (-not $startThreadJob) { + Write-Error (@( + "This feature requires Start-ThreadJob," + "which is included in more recent versions of PowerShell." + ) -join [Environment]::NewLine) + return + } + $InitializationScript = { + filter serverStatus { + $errorCode = $_ + $response.StatusCode = $errorCode + foreach ($pack in $package) { + if ($pack.PartExists("/$errorCode.html")) { + "/$errorCode.html" | servePart + continue nextRequest + } + if ($pack.PartExists("/$errorCode.md")) { + "/$errorCode.md" | servePart + continue nextRequest + } + } + $response.Close() + } + + + filter findPart { + foreach ($pack in $package) { + try { + if ($pack.PartExists($request.url.localPath)) { + return $request.url.localPath + } + } catch { + + } + } + + $potentialUris = + if ($request.url.LocalPath -and + $request.url.LocalPath -notmatch '\.[^\./]+?$') { + + if ($request.url.localPath -ne '/') { + [IO.Packaging.PackUriHelper]::CreatePartUri($request.url.localPath) + } + if ($Route -and $route[$request.Url.LocalPath]) { + [IO.Packaging.PackUriHelper]::CreatePartUri("$($route[$request.Url.LocalPath])") + } + $noTrailingSlash = ($request.url.LocalPath -replace '/$') + if ($noTrailingSlash) { + try { + [IO.Packaging.PackUriHelper]::CreatePartUri($noTrailingSlash) + } catch { + Write-Warning "$_ - $($request.Url) - $($request.Url.LocalPath)" + } + } + $noTrailingSlash + '/index.html' + $noTrailingSlash + '/README.html' + $noTrailingSlash + '/README.md' + $noTrailingSlash + '/index.json' + $noTrailingSlash + '/index.xml' + $noTrailingSlash + '.html' + $noTrailingSlash + '.md' + } elseif ($request.url.LocalPath) { + if ($Route -and $route[$request.Url.LocalPath]) { + [IO.Packaging.PackUriHelper]::CreatePartUri("$($route[$request.Url.LocalPath])") + } + try { + [IO.Packaging.PackUriHelper]::CreatePartUri($request.url.LocalPath) + } catch { + Write-Warning "$_ - $($request.Url) - $($request.Url.LocalPath)" + } + } else { + @() + } + + :nextPotential foreach ($potentialUri in $potentialUris) { + :nextPack foreach ($pack in $package) { + if ($pack.PartExists($potentialUri)) { + return $potentialUri + } + :nextPart foreach ($part in $pack.GetParts()) { + if ($part.Uri -eq $potentialUri) { + return $part.Uri + } + } + } + } + } + + # declare a little filter to serve a part + filter servePart { + $uriPart = $_ + $packagePart = + foreach ($pack in $package) { + if ($pack.PartExists -and $pack.PartExists($uriPart)) { + $pack.GetPart($uriPart) + break + } + } + if ($uriPart -match '\.[^.]+?$' -and + $TypeMap[$matches.0] + ) { + $response.ContentType = $TypeMap[$matches.0] + } else { + $response.ContentType = $packagePart.ContentType + } + + # If we are invokable and are dealing with a script file + if ($Route -and + @($route.Values) -contains $packagePart.Uri -and + $packagePart.Uri -match '\.ps1$' -and + $packagePart.Reader + ) { + if ($packagePart.Uri -match '\.ps1$') { + + $packageScript = $packagePart.Read() + $packageParameterNames = [Ordered]@{} + :nextParameter foreach ($packageScriptParameter in $packageScript.Ast.ParamBlock.Parameters) { + if ($packageScriptParameter.Attributes.typename -match 'hidden') { + continue nextParameter + } + $parameterName = "$($packageScriptParameter.Name.VariablePath.UserPath)" + foreach ($attr in $packageScriptParameter.Attributes) { + if ($attr.typename -ne 'alias') { continue } + foreach ($parameterAlias in $attr.PositionalArguments.Value) { + $packageParameterNames[$parameterAlias] = $parameterName + } + } + $packageParameterNames[$parameterName] = $parameterName + } + + $packageScriptParameters = [Ordered]@{} + if ($request.Url.Query) { + $parsedQuery = [Web.HttpUtility]::ParseQueryString($request.Url.Query) + foreach ($queryKey in $parsedQuery.Keys) { + if (-not $packageParameterNames[$queryKey]) { + continue + } + $parameterName = $packageParameterNames[$queryKey] + if ($null -eq $packageScriptParameters[$parameterName]) { + $packageScriptParameters[$parameterName] = $parsedQuery[$queryKey] + } else { + $packageScriptParameters[$parameterName] = @( + $packageScriptParameters[$parameterName] + ) + $parsedQuery[$queryKey] + } + } + } + + Write-Warning "Invoking $($packagePart.Uri) from $($request.Url)" + + $streamOutput = { + param($reply) + + begin { + if (-not $reply.OutputStream) { throw "no output stream" ; return } + $reply.ProtocolVersion = '1.1' + $reply.SendChunked = $true + } + + process { + $in = $_ + + if ($in.OuterXml) { + $buffer = $OutputEncoding.GetBytes("$($in.OuterXml)") + $reply.OutputStream.Write($buffer, 0, $buffer.Length) + $reply.OutputStream.Flush() + } + elseif ($in.html) { + $buffer = $OutputEncoding.GetBytes("$($in.html)") + $reply.OutputStream.Write($buffer, 0, $buffer.Length) + $reply.OutputStream.Flush() + } + else { + # or the stringification of the result. + $buffer = $OutputEncoding.GetBytes("$in") + $reply.OutputStream.Write($buffer, 0, $buffer.Length) + $reply.OutputStream.Flush() + } + } + + end { + if ($reply.Close) { + $reply.Close() + } + } + } + + try { + & $packageScript @packageScriptParameters | + . $streamOutput $response + } catch { + $response.StatusCode = 500 + if ($response.OutputStream.CanWrite) { + $response.Close( + [Text.Encoding]::UTF8.GetBytes("$_"), $false + ) + } else { + $response.Close() + } + } + } + continue nextRequest + } + + $acceptableTypes = @($request.Headers['Accept'] -split ',') + Write-Host "Accepts $($request.Headers['Accept'])" -ForegroundColor Cyan + if ( + ( + $packagePart.ContentType -eq 'text/markdown' -or + $packagePart.Uri -match '(?>\.md|\.markdown)$' + ) -and + ( + $acceptableTypes[0] -ne 'text/markdown' -and + $request.Headers['Content-Type'] -ne 'text/markdown' + ) + ) { + $response.ContentType = 'text/html' + $response.Close([Text.Encoding]::UTF8.GetBytes("$( + @( + $packagePart | Format-OpenPackage -View Markdown.html + ) -join [Environment]::NewLine + )"), $false) + return + } + + $partStream = $packagePart.GetStream('Open', 'Read') + + Write-Host "$($request.HttpMethod) $uriPart $($response.ContentType)" -ForegroundColor Cyan + + if ($partStream.Length -lt $BufferSize) { + $partStream.CopyTo($response.OutputStream) + $partStream.Close() + $partStream.Dispose() + $response.Close() + return + } + + Start-ThreadJob -Name ($Request.Url -replace '^https?', 'part://') -ScriptBlock { + param($partStream, $Request, $response, $BufferSize = 1mb) + if (-not $partStream) { + if ($response.Close) {$response.Close()} + return + } + if ($request.Method -eq 'HEAD') { + $response.ContentLength64 = $partStream.Length + Write-Verbose "Serving HEAD request $($Request.url) - $partStreamLength" + $partStream.Close() + $null = $partStream.DisposeAsync() + $response.Close() + return + } + + $response.Headers["Accept-Ranges"] = "bytes"; + $range = $request.Headers['Range'] + $rangeStart, $rangeEnd = -1, 0 + if ($range) { + $null = $range -match 'bytes=(?\d{1,})(-(?\d{1,})){0,1}' + $rangeStart, $rangeEnd = ($matches.Start -as [long]), ($matches.End -as [long]) + } + if ($rangeStart -ge 0 -and $rangeEnd -gt 0) { + Write-Verbose -Verbose "Serving Request Range $($Request.url) : $($rangeStart)-$($rangeEnd)" + $buffer = [byte[]]::new($BufferSize) + $null = $partStream.Seek($rangeStart, 'Begin') + $bytesRead = $partStream.Read($buffer, 0, $BufferSize) + $contentRange = "$RangeStart-$($RangeStart + $bytesRead - 1)/$($partStream.Length)" + $response.StatusCode = 206 + $response.ContentLength64 = $bytesRead + $response.Headers["Content-Range"] = $contentRange + $response.OutputStream.Write($buffer, 0, $bytesRead) + $response.OutputStream.Close() + } else { + Write-Verbose -Verbose "Serving Request without range $($Request.url)" + # if that stream has a content length + if ($partStream.Length -gt 0) { + # set the content length + $response.ContentLength64 = $partStream.Length + } + # Then copy the stream to the response. + try { + $partStream.CopyTo($response.OutputStream) + } catch { + Write-Warning "$_" + } + } + $response.Close() + $partStream.Close() + $null = $partStream.DisposeAsync() + } -ThrottleLimit 1kb -ArgumentList $partStream, $request, $response, $BufferSize + + return + } + } + $JobDefinition = { + param([Collections.IDictionary]$IO) + + # unpack our IO into local variables + foreach ($variableName in $IO.Keys) { + $ExecutionContext.SessionState.PSVariable.Set($variableName, $IO[$variableName]) + } + + if ($ImportModule) { + Write-Warning "Importing Modules $importModule" + $imported = Import-Module -Name $ImportModule -PassThru + Write-Warning "Imported Modules $imported" + } + + # declare some inner functions to help serve + + $ServerStartTime = [DateTime]::Now + + # and start listening + :nextRequest while ($httpListener.IsListening) { + $getContextAsync = $httpListener.GetContextAsync() + # wait in short increments to minimize CPU impact and stay snappy + while (-not $getContextAsync.Wait(23)) { + # while we're waiting, check our lifespan + if ($Lifespan -and ( + ($ServerStartTime + $Lifespan) -ge [DateTime]::Now + )) { + $httpListener.Stop() + } + } + + # If the counter is a long + if ($IO.Counter -is [long]) { + $IO.Counter += 1 # increment the counter. + } + + # Get our listener context + $context = $getContextAsync.Result + + # and break that into a result and response + $request, $response = $context.Request, $context.Response + + if ($request.Url.LocalPath -eq '/favicon.ico') { + $response.StatusCode = 404 + $response.Close() + continue nextRequest + } + + $MessageData = [Ordered]@{ + Url = $request.Url + Context = $context + Request = $request + Response = $response + Package = $package + Handled = $false + } + if ($parentRunspace) { + $requestEvent = $parentRunspace.Events.GenerateEvent( + $request.Url.Scheme, + $httpListener, + @($request, $response, $context), + $MessageData, + $false, + $true + ) + } + + # If the request has no output stream or it was handled by an event + if (-not $response.OutputStream -or $MessageData.Handled) { + # continue to the next request + continue nextRequest + } + + $requestTime = $requestEvent.TimeGenerated + Write-Host -ForegroundColor Cyan "[$($requestTime.ToString('o'))] $($request.HttpMethod) $($request.Url)" + # If they asked for an inappropriate method + if ($request.HttpMethod -notin $Allow) { + # use the appropriate status code + + Write-Host -ForegroundColor Red "[$($requestTime.ToString('o'))] 405 $($request.HttpMethod) $($request.Url)" + 405 | serverStatus + # and continue to the next request + continue nextRequest + } + + + # If we're allowing additional methods, we can easily do CRUD operations + switch -regex ($request.HttpMethod) { + # Put or Post changes file content. + '(?>put|post)' { + $anythingChanged = $false + $memoryStream = [IO.MemoryStream]::new() + if ($request.InputStream.CanRead) { + $request.InputStream.CopyTo($memoryStream) + } + + :packageWrite foreach ($pack in $package) { + if ($pack.FileOpenAccess -ne 'ReadWrite') { + continue + } + + $partStream = + if ($pack.PartExists($request.Url.LocalPath)) { + $pack.GetPart($request.Url.LocalPath).GetStream() + } else { + $newPart = $pack.CreatePart($request.Url.LocalPath, $request.ContentType, 'Superfast') + $newPart.GetStream() + } + + $null = $memoryStream.Seek(0, 'begin') + $partStream.SetLength($memoryStream.Length) + $memoryStream.CopyTo($partStream) + $partStream.Close() + $partStream.Dispose() + $anythingChanged = $true + break packageWrite + } + + if ($anythingChanged -and + $request.HttpMethod -eq 'put') { + 201 | serverStatus + continue nextRequest + } + } + 'delete' { + $anythingDeleted = $false + :packageDelete foreach ($pack in $package) { + if ($pack.FileOpenAccess -eq 'ReadWrite' -and $pack.PartExists( + $request.Url.LocalPath + )) { + $pack.DeletePart($request.Url.LocalPath) + $anythingDeleted = $true + break packageDelete + } + } + if ($anythingDeleted) { + 204 | serverStatus + continue nextRequest + } + } + } + + $foundPart = . findPart + + if ($foundPart) { + $foundPart | servePart + continue nextRequest + } + + $uriPart = ($request.Url.LocalPath -replace '/$') + '/' + + if ($uriPart -match '/$') { + Write-Host -ForegroundColor Cyan "[$($requestTime.ToString('o'))] $($request.HttpMethod) $($request.Url) Missing index, generating" + + $response.ContentType = 'text/html' + $response.Close( + $OutputEncoding.GetBytes("$( + $pack | Format-OpenPackage -View Tree.html -Option @{ + FilePattern = [regex]::Escape($request.Url.LocalPath) + } + )"), $false + ) + + continue nextRequest + } + else { + Write-Host -ForegroundColor Cyan "[$($requestTime.ToString('o'))] Marco $($request.HttpMethod) $($request.Url)" + } + + # If we did not find a part, set the appropriate status code + 404 | serverStatus + } + } + $generateEvent = [Runspace]::DefaultRunspace.Events.GenerateEvent + } + + end { + # Rapidly collect all pipeline input + $allInput = @($input) + + # Get our packages + # Each server can have any number of packages + # The order packages are defined is the order they are resolved + # This allows us to have any number of layers, in any order we want. + $packages = @( + # First up, lets process our input objects + # (piped in objects come first) + $remainingInput = @() + foreach ($in in $allInput) { + # Anything that is a package works + if ($in -is [IO.Packaging.Package]) { + $in + } + # so does anything that has a .Package property + elseif ( + $in.Package -is [IO.Packaging.Package] + ) { + $in.Package + } + # anything else we will pipe to Get-OpenPackage + else + { + $remainingInput += $in + } + } + # Now lets check a bound -InputObject + # If piped in, this will potentially be a duplicated + # (because `$InputObject` will contain the last bound value) + foreach ($in in $InputObject) { + # Skip any input we already have + if ($allInput -contains $in) { + continue + } + # If the -InputObject was a package + if ($in -is [IO.Packaging.Package]) { + $in # this works + } + # Otherwise, if the -InputObject has a .Package + elseif ( + $in.Package -is [IO.Packaging.Package] -and + # and it is not a package we already have collected + ($allInput.Package -notcontains $in.Package) + ) { + # then .Package works. + $in.Package + } + # Otherwise, we will pipe remaining input to Get-OpenPackage + elseif ($remainingInput -notcontains $in) { + $remainingInput += $in + } + } + # If there was remaining input + if ($remainingInput) { + # pipe it to Get-OpenPackage + $remainingInput | Get-OpenPackage @ArgumentList + } + # If we had arguments, + elseif ($ArgumentList) { + # call Get-OpenPackage. + Get-OpenPackage @ArgumentList + } + ) + + # Now we have a list of all of of potential packages + # Let's make one last pass thru for safety and sanity + $package = @( + # and include only the packages + foreach ($pack in $packages) { + if ($pack -is [IO.Packaging.Package]) { + $pack + } + } + ) + + # If we have no actual packages, return. + if (-not $package) { return } + + # Now that we know _what_ we're serving, + # create a server by creating an http listener. + $httpListener = [Net.HttpListener]::new() + # and adding the root url prefix + $httpListener.Prefixes.Add($RootUrl) + + # Create an IO object to populate the background runspace + # By using an IO object, we can more easily communicate between runspaces. + $IO = [Ordered]@{ + HttpListener = $httpListener + Package = $package + ParentRunspace = [Runspace]::DefaultRunspace + Counter = [long]0 + } + $PSBoundParameters + + # If the IO does not have a typemap + if (-not $io.TypeMap) { + # copy the typemap + $io.TypeMap = $TypeMap + } + + # If the IO does not have an -Allow + if (-not $io.Allow) { + # use the default values for Allow. + $io.Allow = $Allow + } + # If the IO does not have a bufferSize + if (-not $io.BufferSize) { + # use the default buffer size + $io.BufferSize = $BufferSize + } + + # If the IO does not have a route table + if (-not $io.Route) { + # use the default route table (this should be empty) + $io.Route = $Route + } + + # Now we're almost ready to serve, + # it's time to send an event. + # We need to prepare our message data with the relevant info + $messageData = [Ordered]@{ + RootUrl = $RootUrl + Package = $package + HttpListener = $httpListener + InvocationInfo = $MyInvocation + Command = $MyInvocation.MyCommand + IO = $IO + } + + # Generate an event + $StartOpenPackageEvent = $generateEvent.Invoke( + 'Start-OpenPackage', # for Start-OpenPackage + $MyInvocation.MyCommand, # sent by this command + @( + # containing MyInvocation and MessageData + $MyInvocation, $messageData + ), + # And sending the message data dictionary along + $messageData, + # process in the current thread + $true, + # and wait for completion. + $true + ) + + # If the event was processed, and they said any form of "no" + if ($StartOpenPackageEvent.MessageData.Rejected -or + $StartOpenPackageEvent.MessageData.Reject -or + $StartOpenPackageEvent.MessageData.No -or + $StartOpenPackageEvent.MessageData.Deny + ) { + Write-Warning "Will not $($MyInvocation.Line)" + return + } + + # Now let's start our listener + try { + $IO.HttpListener.Start() + } catch { + # if we could not, return + $PSCmdlet.WriteError($_) + return + } + + # If that worked, + if ($?) { + # write a warning. + # This serves two purposes: + # 1. It lets people know that a server is running + # 2. It gives people something a link to click. + Write-Warning "Listening on $rootUrl" + } + + # If there was no package identifier + if (-not $package.PackageProperties.Identifier) { + # write another warning. + Write-Warning "No Package Identifier" + } + + # Get ready to import our own module. + $IO.ImportModule = @( + if ($myInvocation.MyCommand.Module) { + "$($MyInvocation.MyCommand.Module | Split-Path)" + } else { + Get-Module OP | Split-Path + } + ) + + # Remove the trailing slash from the root url + # (prefixes require it, but it makes adding paths more annoying) + $RootUrl = $RootUrl -replace '/$' + # Prepare our parameters for Start-ThreadJob + $JobParameters = [Ordered]@{ + ScriptBlock=$JobDefinition + ArgumentList=$IO + Name=$RootUrl + ThrottleLimit = $ThrottleLimit + InitializationScript = $InitializationScript + } + + foreach ($nodeNumber in 1..$NodeCount) { + # Start a thread job and add our properties + $startedJob = Start-ThreadJob @JobParameters | + Add-Member NoteProperty IO $IO -Force -PassThru | + Add-Member NoteProperty HttpListener $httpListener -Force -PassThru | + Add-Member NoteProperty Package $package -Force -PassThru | + Add-Member NoteProperty Url $RootUrl -Force -PassThru + + # Decorate our return + $startedJob.pstypenames.add('OpenPackage.Server') + # and output our server + $startedJob + } + } +} \ No newline at end of file diff --git a/Commands/Uninstall-OpenPackage.ps1 b/Commands/Uninstall-OpenPackage.ps1 new file mode 100644 index 0000000..c42d3bc --- /dev/null +++ b/Commands/Uninstall-OpenPackage.ps1 @@ -0,0 +1,92 @@ +function Uninstall-OpenPackage +{ + <# + .SYNOPSIS + Uninstalls OpenPackages + .DESCRIPTION + Uninstalls one or more OpenPackages. + .NOTES + Because installed packages are just directories, + this is a very light wrapper of Remove-Item. + + If you provide an -Identifier, + .LINK + Install-OpenPackage + .LINK + Remove-Item + #> + [CmdletBinding(SupportsShouldProcess,ConfirmImpact='High')] + [Alias( + 'Uninstall-OP', 'usop', 'usOpenPackage' + )] + param( + # The package identifier. + # If this is a fully qualified directory or file name, it will be removed. + [Parameter(Mandatory,ParameterSetName='Identifier', ValueFromPipelineByPropertyName)] + [string[]] + $Identifier, + + # The package version + [Parameter(ParameterSetName='Identifier',ValueFromPipelineByPropertyName)] + [string[]] + $Version, + + # The direct path to any number of packages. + # This will Remove-Item these paths + [Parameter(ParameterSetName='PackagePath',ValueFromPipelineByPropertyName)] + [string[]] + $PackagePath, + + # The root location where packages are stored. + # By default, this will be the locations specified in `$env:OpenPackagePath` + [string[]] + $PackageRoot = @( + if ($IsLinux -or $IsMacOS) { + $env:OpenPackagePath -split ':' + } else { + $env:OpenPackagePath -split ';' + } + ) + ) + + process { + # Get any potential paths in any package root + $potentialPaths = @(foreach ($packageLocation in $PackageRoot) { + # We have to have at least one identifier + foreach ($packageId in $PSBoundParameters['Identifier']) { + # If version was supplied + if ($version) { + foreach ($packageVersion in $version) { + # look for each potential version. + Join-Path $packageLocation "$packageId/$packageVersion" + } + } else { + # If no version was supplied, look for the package id. + Join-Path $packageLocation $packageId + } + } + }) + + # If we have provided a package path + if ($psBoundParameters['PackagePath']) { + # ignore any versioned paths and use that directly. + $potentialPaths = $psBoundParameters['PackagePath'] + } + + + # Check each potential path + foreach ($potentialPath in $potentialPaths) { + # If the path does not exist, continue + if (-not (Test-Path $potentialPath)) { continue } + if ($WhatIfPreference) { # If -WhatIf was passed, + Get-Item $potentialPath #output the item + continue # and continue. + } + # If we confirmed we want to remove it + if ($PSCmdlet.ShouldProcess("Uninstall $potentialPath")) { + # call Remove-Item with `-Recurse` and `-Force` + Remove-Item -Recurse -Force -Path $potentialPath + } + } + } +} \ No newline at end of file diff --git a/Commands/Write-OpenPackage.ps1 b/Commands/Write-OpenPackage.ps1 new file mode 100644 index 0000000..64a06d9 --- /dev/null +++ b/Commands/Write-OpenPackage.ps1 @@ -0,0 +1,143 @@ +function Write-OpenPackage +{ + <# + .SYNOPSIS + Writes bytes to an Open Package + .DESCRIPTION + Writes bytes directly to an Open Package part. The part must already exist. + + This can be used to rapidly update small segments of a package. + + It can also corrupt package contents, and should be used with care. + + To set package parts, use Set-OpenPackage + .EXAMPLE + $package = OP @{"hello.txt" = "Hello world"} + $package | + Write-OpenPackage -Uri /hello.txt -Buffer ($outputEncoding.GetBytes("y")) | + Get-OpenPackage ./hello.txt + .LINK + Get-OpenPackage + .LINK + Read-OpenPackage + .LINK + Set-OpenPackage + #> + [Alias('Write-OP', 'wrop', 'wrOpenPackage')] + param( + # The Package Part Uri. This is the path to the content within a package. + [Parameter(Mandatory,ValueFromPipelineByPropertyName)] + [Alias('PartUri')] + [Uri] + $Uri, + + # The content to write. + [Parameter(Mandatory,ValueFromPipelineByPropertyName)] + [Alias('Buffer','Text')] + [PSObject] + $Content, + + # The serialization depth. + [Parameter(ValueFromPipelineByPropertyName)] + [ValidateRange(1,100)] + [int] + $Depth = $( + if ($FormatEnumerationLimit * 2 -gt 1) { + $FormatEnumerationLimit * 2 + } else { + 4 + } + ), + + # The options used to write the content. + [Parameter(ValueFromPipelineByPropertyName)] + [Alias('Options')] + [Collections.IDictionary] + $Option = [Ordered]@{}, + + # The starting location for the write. + [Parameter(ValueFromPipelineByPropertyName)] + [Alias('PartStart')] + [long] + $RangeStart = 0, + + # The package. + [Parameter(ValueFromPipeline)] + [Alias('Package')] + [PSObject] + $InputObject + ) + + process { + # If the input object is not a package, + if ($InputObject -isnot [IO.Packaging.Package]) { + # look for a property named package + if ($inputObject.Package -is [IO.Packaging.Package]) { + # and use that as our input. + $inputObject = $inputObject.Package + } else { + # Otherwise, pass thru the input. + return $InputObject + } + } + + + $SlashPart = "$uri" -replace '^/?', '/' + + if (-not $InputObject.PartExists($SlashPart)) { + return + } + + + + $part = $InputObject.GetPart($SlashPart) + if (-not $part) { return } + + # If we have a writer for the part, and no starting range + if ( + (-not $PSBoundParameters.ContainsKey('RangeStart')) -and + $part.Writer -and $part.Write + ) { + # * Prepare the options + if (-not $option) { + $Option = [Ordered]@{} + } + # * Copy in depth + if (-not $option.Depth) { + $Option.Depth = $Depth + } + # * Call the writer + $part.Write($Content, $option) + # * and return. + return $InputObject + } + + # Otherwise get the stream + $partStream = $part.GetStream() + # seek to the location + if ($RangeStart -lt $partStream.Length) { + $null = $partStream.Seek($RangeStart, 'Begin') + } + + # Make sure our content buffer is bytes + $buffer = if ($content -is [byte[]]) { + $content + } elseif ($( + $contentAsBytes = $content -as [byte[]]; $contentAsBytes + )) { + $contentAsBytes + } else { + "$content" + } + + # write the bytes to the stream + $partStream.Write($Buffer, 0, $Buffer.Length) + + # close up + $partStream.Close() + $partStream.Dispose() + + # and return the input object. + return $InputObject + } +} \ No newline at end of file diff --git a/OP.op.ps1 b/OP.op.ps1 new file mode 100644 index 0000000..5f03e73 --- /dev/null +++ b/OP.op.ps1 @@ -0,0 +1,9 @@ +$imported = Import-Module ./OP.psd1 -Force -PassThru + +$op = $imported | Get-OpenPackage -Exclude '*/_site/*', "*/$($imported.name).zip*" + +$op.Palette = 'https://cdn.jsdelivr.net/gh/2bitdesigns/4bitcss@latest/css/Konsolas.css' + +$publishedFiles = $op | Publish-OpenPackage -Publisher Site, at.markpub.markdown, org.poshweb.op, Markdown + +$op | Export-OpenPackage -DestinationPath "$($imported.name).zip" -Force diff --git a/OP.psd1 b/OP.psd1 new file mode 100644 index 0000000..8ac3667 --- /dev/null +++ b/OP.psd1 @@ -0,0 +1,430 @@ +# +# Module manifest for module 'OP' +# +# Generated by: James Brundage +# +# Generated on: 10/19/2025 +# + +@{ + +# Script module or binary module file associated with this manifest. +RootModule = 'OP.psm1' + +# Version number of this module. +ModuleVersion = '0.1' + +# Supported PSEditions +# CompatiblePSEditions = @() + +# ID used to uniquely identify this module +GUID = 'a5d8b33f-074f-435f-8397-dda2d7f15ecf' + +# Author of this module +Author = 'James Brundage' + +# Company or vendor of this module +CompanyName = 'Start-Automating' + +# Copyright statement for this module +Copyright = '2025-2026 Start-Automating' + +# Description of the functionality provided by this module +Description = 'Open Packages are OverPowered' + +# Minimum version of the PowerShell engine required by this module +# PowerShellVersion = '' + +# Name of the PowerShell host required by this module +# PowerShellHostName = '' + +# Minimum version of the PowerShell host required by this module +# PowerShellHostVersion = '' + +# Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# DotNetFrameworkVersion = '' + +# Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# ClrVersion = '' + +# Processor architecture (None, X86, Amd64) required by this module +# ProcessorArchitecture = '' + +# Modules that must be imported into the global environment prior to importing this module +# RequiredModules = @() + +# Assemblies that must be loaded prior to importing this module +# RequiredAssemblies = @() + +# Script files (.ps1) that are run in the caller's environment prior to importing this module. +# ScriptsToProcess = @() + +# Type files (.ps1xml) to be loaded when importing this module +TypesToProcess = @('OP.types.ps1xml') + +# Format files (.ps1xml) to be loaded when importing this module +# FormatsToProcess = @() + +# 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 = @( + 'Close-OpenPackage' + 'Copy-OpenPackage' + 'Export-OpenPackage' + 'Format-OpenPackage' + 'Get-OpenPackage' + 'Install-OpenPackage' + 'Join-OpenPackage' + 'Lock-OpenPackage' + 'New-OpenPackage' + 'Publish-OpenPackage' + 'Read-OpenPackage' + 'Remove-OpenPackage' + 'Select-OpenPackage' + 'Set-OpenPackage' + 'Split-OpenPackage' + 'Start-OpenPackage' + 'Uninstall-OpenPackage' + 'Write-OpenPackage' +) + + +# 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 = @() + +# Variables to export from this module +VariablesToExport = @() + +# Aliases 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 aliases to export. +AliasesToExport = @( + 'Close-OP', 'csop','csOpenPackage' + + 'Copy-OP','cpop','cpOpenPackage' + + 'Expand-OP', 'enop','enOpenPackage' + + 'Export-OP','epop','epOpenPackage', + 'Save-OpenPackage','Save-OP','svop', 'svOpenPackage' + + 'Format-OP', 'fop', 'fOpenPackage' + + 'Get-OP', 'OP', 'OpenPackage','gOpenPackage' + 'Open-OpenPackage', 'Open-OP', 'opop', 'opOpenPackage' + + 'Install-OP', 'inop', 'inOpenPackage', + 'Expand-OpenPackage','Expand-OP', 'enop','enOpenPackage' + + 'Join-OP','jop','jOpenPackage' + + 'Lock-OP', 'lkop','lkOpenPackage' + + 'New-OP','nop', 'nOpenPackage' + + 'Publish-OP', 'pbop', 'pbOpenPackage' + + 'Read-OP', 'rdop', 'rdOpenPackage' + + 'Remove-OP','rop','rOpenPackage' + + 'Select-OP','scop', 'scOpenPackage' + + 'Set-OP','sop','sOpenPackage' + + 'Split-OP','slop', 'slOpenPackage' + + 'Start-OP','stOpenPackage' + + 'Uninstall-OP', 'usop', 'usOpenPackage' +) + +# DSC resources to export from this module +# DscResourcesToExport = @() + +# List of all modules packaged with this module +# ModuleList = @() + +# List of all files packaged with this module +# FileList = @() + +# Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. +PrivateData = @{ + + PSData = @{ + + # Tags applied to this module. These help with module discovery in online galleries. + Tags = @('OpenPackage', + 'Zip', + 'Nuget', + 'PowerShellGallery', + 'Git', + 'AtProto', + 'WebServer', + 'OpenPlatform', + 'OpenProtocol', + 'Overpowered' + ) + + # A URL to the license for this module. + LicenseUri = 'https://github.com/PoshWeb/OP/tree/main/LICENSE' + + # A URL to the main website for this project. + ProjectUri = 'https://github.com/PoshWeb/OP' + + # A URL to an icon representing this module. + IconUri = 'https://raw.githubusercontent.com/PoshWeb/OP/main/Assets/OP-Blue-Text.png' + + # ReleaseNotes of this module + ReleaseNotes = @' +Initial Release of OP. + +* [#1](https://github.com/PoshWeb/OP/issues/1) +* [#2](https://github.com/PoshWeb/OP/issues/2) +* [#3](https://github.com/PoshWeb/OP/issues/3) +* [#5](https://github.com/PoshWeb/OP/issues/5) +* [#7](https://github.com/PoshWeb/OP/issues/7) +* [#8](https://github.com/PoshWeb/OP/issues/8) +* [#10](https://github.com/PoshWeb/OP/issues/10) +* [#11](https://github.com/PoshWeb/OP/issues/11) +* [#12](https://github.com/PoshWeb/OP/issues/12) +* [#13](https://github.com/PoshWeb/OP/issues/13) +* [#14](https://github.com/PoshWeb/OP/issues/14) +* [#15](https://github.com/PoshWeb/OP/issues/15) +* [#16](https://github.com/PoshWeb/OP/issues/16) +* [#17](https://github.com/PoshWeb/OP/issues/17) +* [#18](https://github.com/PoshWeb/OP/issues/18) +* [#19](https://github.com/PoshWeb/OP/issues/19) +* [#20](https://github.com/PoshWeb/OP/issues/20) +* [#21](https://github.com/PoshWeb/OP/issues/21) +* [#22](https://github.com/PoshWeb/OP/issues/22) +* [#23](https://github.com/PoshWeb/OP/issues/23) +* [#24](https://github.com/PoshWeb/OP/issues/24) +* [#25](https://github.com/PoshWeb/OP/issues/25) +* [#26](https://github.com/PoshWeb/OP/issues/26) +* [#27](https://github.com/PoshWeb/OP/issues/27) +* [#28](https://github.com/PoshWeb/OP/issues/28) +* [#29](https://github.com/PoshWeb/OP/issues/29) +* [#30](https://github.com/PoshWeb/OP/issues/30) +* [#31](https://github.com/PoshWeb/OP/issues/31) +* [#32](https://github.com/PoshWeb/OP/issues/32) +* [#33](https://github.com/PoshWeb/OP/issues/33) +* [#34](https://github.com/PoshWeb/OP/issues/34) +* [#35](https://github.com/PoshWeb/OP/issues/35) +* [#38](https://github.com/PoshWeb/OP/issues/38) +* [#39](https://github.com/PoshWeb/OP/issues/39) +* [#40](https://github.com/PoshWeb/OP/issues/40) +* [#41](https://github.com/PoshWeb/OP/issues/41) +* [#44](https://github.com/PoshWeb/OP/issues/44) +* [#45](https://github.com/PoshWeb/OP/issues/45) +* [#46](https://github.com/PoshWeb/OP/issues/46) +* [#47](https://github.com/PoshWeb/OP/issues/47) +* [#48](https://github.com/PoshWeb/OP/issues/48) +* [#49](https://github.com/PoshWeb/OP/issues/49) +* [#50](https://github.com/PoshWeb/OP/issues/50) +* [#51](https://github.com/PoshWeb/OP/issues/51) +* [#52](https://github.com/PoshWeb/OP/issues/52) +* [#53](https://github.com/PoshWeb/OP/issues/53) +* [#57](https://github.com/PoshWeb/OP/issues/57) +* [#58](https://github.com/PoshWeb/OP/issues/58) +* [#59](https://github.com/PoshWeb/OP/issues/59) +* [#61](https://github.com/PoshWeb/OP/issues/61) +* [#63](https://github.com/PoshWeb/OP/issues/63) +* [#64](https://github.com/PoshWeb/OP/issues/64) +* [#65](https://github.com/PoshWeb/OP/issues/65) +* [#67](https://github.com/PoshWeb/OP/issues/67) +* [#68](https://github.com/PoshWeb/OP/issues/68) +* [#74](https://github.com/PoshWeb/OP/issues/74) +* [#78](https://github.com/PoshWeb/OP/issues/78) +* [#80](https://github.com/PoshWeb/OP/issues/80) +* [#81](https://github.com/PoshWeb/OP/issues/81) +* [#82](https://github.com/PoshWeb/OP/issues/82) +* [#83](https://github.com/PoshWeb/OP/issues/83) +* [#84](https://github.com/PoshWeb/OP/issues/84) +* [#85](https://github.com/PoshWeb/OP/issues/85) +* [#86](https://github.com/PoshWeb/OP/issues/86) +* [#87](https://github.com/PoshWeb/OP/issues/87) +* [#88](https://github.com/PoshWeb/OP/issues/88) +* [#89](https://github.com/PoshWeb/OP/issues/89) +* [#90](https://github.com/PoshWeb/OP/issues/90) +* [#91](https://github.com/PoshWeb/OP/issues/91) +* [#92](https://github.com/PoshWeb/OP/issues/92) +* [#93](https://github.com/PoshWeb/OP/issues/93) +* [#94](https://github.com/PoshWeb/OP/issues/94) +* [#95](https://github.com/PoshWeb/OP/issues/95) +* [#96](https://github.com/PoshWeb/OP/issues/96) +* [#97](https://github.com/PoshWeb/OP/issues/97) +* [#98](https://github.com/PoshWeb/OP/issues/98) +* [#99](https://github.com/PoshWeb/OP/issues/99) +* [#100](https://github.com/PoshWeb/OP/issues/100) +* [#101](https://github.com/PoshWeb/OP/issues/101) +* [#102](https://github.com/PoshWeb/OP/issues/102) +* [#103](https://github.com/PoshWeb/OP/issues/103) +* [#104](https://github.com/PoshWeb/OP/issues/104) +* [#105](https://github.com/PoshWeb/OP/issues/105) +* [#106](https://github.com/PoshWeb/OP/issues/106) +* [#107](https://github.com/PoshWeb/OP/issues/107) +* [#108](https://github.com/PoshWeb/OP/issues/108) +* [#109](https://github.com/PoshWeb/OP/issues/109) +* [#110](https://github.com/PoshWeb/OP/issues/110) +* [#111](https://github.com/PoshWeb/OP/issues/111) +* [#112](https://github.com/PoshWeb/OP/issues/112) +* [#113](https://github.com/PoshWeb/OP/issues/113) +* [#114](https://github.com/PoshWeb/OP/issues/114) +* [#115](https://github.com/PoshWeb/OP/issues/115) +* [#116](https://github.com/PoshWeb/OP/issues/116) +* [#117](https://github.com/PoshWeb/OP/issues/117) +* [#118](https://github.com/PoshWeb/OP/issues/118) +* [#119](https://github.com/PoshWeb/OP/issues/119) +* [#120](https://github.com/PoshWeb/OP/issues/120) +* [#121](https://github.com/PoshWeb/OP/issues/121) +* [#122](https://github.com/PoshWeb/OP/issues/122) +* [#123](https://github.com/PoshWeb/OP/issues/123) +* [#124](https://github.com/PoshWeb/OP/issues/124) +* [#125](https://github.com/PoshWeb/OP/issues/125) +* [#126](https://github.com/PoshWeb/OP/issues/126) +* [#127](https://github.com/PoshWeb/OP/issues/127) +* [#128](https://github.com/PoshWeb/OP/issues/128) +* [#129](https://github.com/PoshWeb/OP/issues/129) +* [#130](https://github.com/PoshWeb/OP/issues/130) +* [#131](https://github.com/PoshWeb/OP/issues/131) +* [#132](https://github.com/PoshWeb/OP/issues/132) +* [#133](https://github.com/PoshWeb/OP/issues/133) +* [#134](https://github.com/PoshWeb/OP/issues/134) +* [#135](https://github.com/PoshWeb/OP/issues/135) +* [#136](https://github.com/PoshWeb/OP/issues/136) +* [#137](https://github.com/PoshWeb/OP/issues/137) +* [#138](https://github.com/PoshWeb/OP/issues/138) +* [#139](https://github.com/PoshWeb/OP/issues/139) +* [#140](https://github.com/PoshWeb/OP/issues/140) +* [#141](https://github.com/PoshWeb/OP/issues/141) +* [#142](https://github.com/PoshWeb/OP/issues/142) +* [#143](https://github.com/PoshWeb/OP/issues/143) +* [#144](https://github.com/PoshWeb/OP/issues/144) +* [#145](https://github.com/PoshWeb/OP/issues/145) +* [#146](https://github.com/PoshWeb/OP/issues/146) +* [#147](https://github.com/PoshWeb/OP/issues/147) +* [#148](https://github.com/PoshWeb/OP/issues/148) +* [#149](https://github.com/PoshWeb/OP/issues/149) +* [#150](https://github.com/PoshWeb/OP/issues/150) +* [#151](https://github.com/PoshWeb/OP/issues/151) +* [#152](https://github.com/PoshWeb/OP/issues/152) +* [#153](https://github.com/PoshWeb/OP/issues/153) +* [#154](https://github.com/PoshWeb/OP/issues/154) +* [#155](https://github.com/PoshWeb/OP/issues/155) +* [#156](https://github.com/PoshWeb/OP/issues/156) +* [#157](https://github.com/PoshWeb/OP/issues/157) +* [#158](https://github.com/PoshWeb/OP/issues/158) +* [#159](https://github.com/PoshWeb/OP/issues/159) +* [#160](https://github.com/PoshWeb/OP/issues/160) +* [#161](https://github.com/PoshWeb/OP/issues/161) +* [#162](https://github.com/PoshWeb/OP/issues/162) +* [#163](https://github.com/PoshWeb/OP/issues/163) +* [#164](https://github.com/PoshWeb/OP/issues/164) +* [#165](https://github.com/PoshWeb/OP/issues/165) +* [#166](https://github.com/PoshWeb/OP/issues/166) +* [#167](https://github.com/PoshWeb/OP/issues/167) +* [#169](https://github.com/PoshWeb/OP/issues/169) +* [#170](https://github.com/PoshWeb/OP/issues/170) +* [#171](https://github.com/PoshWeb/OP/issues/171) +* [#172](https://github.com/PoshWeb/OP/issues/172) +* [#173](https://github.com/PoshWeb/OP/issues/173) +* [#174](https://github.com/PoshWeb/OP/issues/174) +* [#175](https://github.com/PoshWeb/OP/issues/175) +* [#176](https://github.com/PoshWeb/OP/issues/176) +* [#177](https://github.com/PoshWeb/OP/issues/177) +* [#178](https://github.com/PoshWeb/OP/issues/178) +* [#179](https://github.com/PoshWeb/OP/issues/179) +* [#180](https://github.com/PoshWeb/OP/issues/180) +* [#181](https://github.com/PoshWeb/OP/issues/181) +* [#182](https://github.com/PoshWeb/OP/issues/182) +* [#183](https://github.com/PoshWeb/OP/issues/183) +* [#184](https://github.com/PoshWeb/OP/issues/184) +* [#185](https://github.com/PoshWeb/OP/issues/185) +* [#186](https://github.com/PoshWeb/OP/issues/186) +* [#187](https://github.com/PoshWeb/OP/issues/187) +* [#188](https://github.com/PoshWeb/OP/issues/188) +* [#189](https://github.com/PoshWeb/OP/issues/189) +* [#190](https://github.com/PoshWeb/OP/issues/190) +* [#191](https://github.com/PoshWeb/OP/issues/191) +* [#192](https://github.com/PoshWeb/OP/issues/192) +* [#193](https://github.com/PoshWeb/OP/issues/193) +* [#194](https://github.com/PoshWeb/OP/issues/194) +* [#195](https://github.com/PoshWeb/OP/issues/195) +* [#196](https://github.com/PoshWeb/OP/issues/196) +* [#197](https://github.com/PoshWeb/OP/issues/197) +* [#198](https://github.com/PoshWeb/OP/issues/198) +* [#199](https://github.com/PoshWeb/OP/issues/199) +* [#200](https://github.com/PoshWeb/OP/issues/200) +* [#202](https://github.com/PoshWeb/OP/issues/202) +* [#203](https://github.com/PoshWeb/OP/issues/203) +* [#204](https://github.com/PoshWeb/OP/issues/204) +* [#205](https://github.com/PoshWeb/OP/issues/205) +* [#206](https://github.com/PoshWeb/OP/issues/206) +* [#207](https://github.com/PoshWeb/OP/issues/207) +* [#208](https://github.com/PoshWeb/OP/issues/208) +* [#211](https://github.com/PoshWeb/OP/issues/211) +* [#212](https://github.com/PoshWeb/OP/issues/212) +* [#213](https://github.com/PoshWeb/OP/issues/213) + +'@ + + # Prerelease string of this module + # Prerelease = '' + + # Flag to indicate whether the module requires explicit user acceptance for install/update/save + # RequireLicenseAcceptance = $false + + # External dependent modules of this module + # ExternalModuleDependencies = @() + + PSIntro = @' +Anything can be a package. + +This command helps you make anything into a package. + +The following types of packages are currently supported: + +* Any [Open Packaging Convention](https://en.wikipedia.org/wiki/Open_Packaging_Conventions) files +* Any directory +* Any `*.zip` file +* Any `*.tar.gz` file +* Any nuget package +* Any git repository +* Any public at protocol URI +* Any dictionary (including nested dictionaries) +* Any url +* Any file + +_Anything can be a package_. + +Once we start to treat anything as a package, we can do amazing things with packages. + +Like: + +* Inspect any packages before we work with them. +* Modify the packages to customize their content. +* Split packages +* Filter our components. +* Join them back together. +* Search package content. +* Work with compressed trees of data. +* Have an in-memory containerized virtual filesystem. +* Serve a package from memory. +* Store data to N package layers. + +To put it simply, open packages are overpowered. +'@ + + } # End of PSData hashtable + +} # End of PrivateData hashtable + +# HelpInfo URI of this module +# HelpInfoURI = '' + +# Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. +# DefaultCommandPrefix = '' + +} + diff --git a/OP.psm1 b/OP.psm1 new file mode 100644 index 0000000..b770d6d --- /dev/null +++ b/OP.psm1 @@ -0,0 +1,29 @@ +$CommandsPath = Join-Path $PSScriptRoot 'Commands' +foreach ($file in Get-ChildItem -Path $CommandsPath -Filter '*-*.ps1') { + if ($file.Name -like '*.*.ps1') { + continue + } + . $file.FullName +} + +if (-not ('IO.Packaging.Package' -as [type])) { + if ($psVersionTable.PSVersion -ge '6.0') { + $addedTypes = Add-type -AssemblyName System.IO.Packaging -PassThru + $packageTypeFound = $addedTypes | Where-Object FullName -eq 'System.IO.Packaging.Package' + if (-not $packageTypeFound) { + Write-Warning "Could not find [System.IO.Packaging.Package]" + } + } else { + Write-Warning ( + @( + "System.IO.Packaging.Package is not included with Windows PowerShell." + "Please download System.IO.Packaging from Nuget:" + "https://www.nuget.org/packages/System.IO.Packaging/" + ) -join [Environment]::NewLine + ) + } +} + +if (-not $env:OpenPackagePath) { + $env:OpenPackagePath = Join-Path $home OpenPackage +} \ No newline at end of file diff --git a/OP.tests.ps1 b/OP.tests.ps1 new file mode 100644 index 0000000..eee0f02 --- /dev/null +++ b/OP.tests.ps1 @@ -0,0 +1,44 @@ +describe OP { + it 'Is a module for open packages' { + $emptyPackage = Get-OpenPackage + $emptyPackage -is [IO.Packaging.Package] + } + it 'Can pack itself' { + $PackSelf = Get-Module OP | Get-OpenPackage + $PackSelf -is [IO.Packaging.Package] + } + it 'Can make a package from a dictionary' { + $Message = "

Hello World

" + $packDictionary = OP @{ + "index.html" = $Message + } + $packDictionary.Count | Should -Be 1 + $packDictionary.GetParts().Read() | Should -Be $Message + } + it 'Can make a package from an at uri' { + $atProfile = op at://mrpowershell.com/app.bsky.actor.profile + $atProfile.FileList | Should -Match 'app\.bsky\.actor\.profile' + $atProfile.GetParts().Read().value.'$type' | Should -Be 'app.bsky.actor.profile' + } + it 'Can make a package from a url' { + $svgPack = op https://MrPowerShell.com/MrPowerShell.svg -First 1 + $svgPack.Count | Should -Be 1 + $svgPack.FileList | Should -Be '/MrPowerShell.svg' + } + it 'Can download a nuget package' { + $nugetPackage = op https://www.nuget.org/packages/System.IO.Packaging + $nugetPackage.nuspec.Package.metadata.id | Should -Be System.IO.Packaging + } + it 'Can download part of a repository' { + $iTermPalettes = + op 'https://github.com/mbadolato/iTerm2-Color-Schemes/blob/master/windowsterminal/' -Force + + $iTermPalettes.GetParts().Read().foreground | + Should -Match '^#[0-9a-f]{6}' + + $iTermPalettes | + Set-OpenPackage -Uri '/CREDITS.md' -ContentType text/markdown -Content ( + Invoke-RestMethod https://raw.githubusercontent.com/mbadolato/iTerm2-Color-Schemes/refs/heads/master/CREDITS.md + ) + } +} diff --git a/OP.turtle.ps1 b/OP.turtle.ps1 new file mode 100644 index 0000000..58357b6 --- /dev/null +++ b/OP.turtle.ps1 @@ -0,0 +1,97 @@ +#requires -Module Turtle +param( +[double] +$Size = 1080, + +[string[]] +$Variants = @( + '' + 'Text' + 'Animated' + 'Animated-Text' + 'Gradient' + 'Gradient-Text' + 'Animated-Gradient' + 'Animated-Gradient-Text' + 'Blue' + 'Blue-Text' + 'Animated-Blue' + 'Animated-Blue-Text' +), + +[string[]] +$PngVariants = @( + '' + 'Text' + 'Blue-Text' + 'Gradient-Text' +), + +[string] +$Destination = './Assets' +) + + +$halfSize = $size / 2 + +if ($psScriptRoot) { + Push-Location $PSScriptRoot +} + +if (-not (Test-Path $Destination)) { + $null = New-Item -ItemType Directory -Path $Destination +} + + +foreach ($variant in $variants) { + $fileName = "OP-$variant" -replace '-$' + + $Logo = + 🐢 id $fileName title $fileName Arcygon $size $halfSize 4 StrokeWidth '1%' + + if ($variant -match 'Animated') { + $logo = $logo | 🐢 duration '00:00:42' morph @( + 🐢 @('circleArc', $halfSize, 90, 'forward', $size, 'rotate',90 * 4) + 🐢 @('circleArc', $halfSize, -90, 'forward', $size, 'rotate',90 * 4) + 🐢 @('circleArc', $halfSize, 90, 'forward', $size, 'rotate',90 * 4) + ) + } + + if ($variant -match 'Text') { + $logo = $logo | + 🐢 turtles @( + 🐢 viewbox $halfSize text OP FontSize ($size/4) FontFamily 'sans-serif' @( + if ($variant -match 'Text') { + 'stroke', '#4488ff' + } + elseif ($variant -match 'Gradient') { + 'stroke', '#224488', '#4488ff', '#224488' + } + ) + ) + } + + if ($variant -match 'Blue') { + $logo.Stroke = '#4488ff' + } + + if ($variant -match 'Gradient') { + $logo.Stroke = '#224488', '#4488ff', '#224488' + } + + + $fileName = "OP-$variant" -replace '-$' + + $logo | Save-Turtle -FilePath ( + Join-Path $destination "./$fileName.svg" + ) + + if ($pngVariants -contains $variant) { + $logo | Save-Turtle -FilePath ( + Join-Path $destination "./$fileName.png" + ) + } +} + +return + diff --git a/OP.types.ps1xml b/OP.types.ps1xml new file mode 100644 index 0000000..897097b --- /dev/null +++ b/OP.types.ps1xml @@ -0,0 +1,9887 @@ + + + + System.IO.Packaging.Package + + + PSStandardMembers + + + DefaultDisplayPropertySet + + Identifier + FileList + + + + + + _ + Underscore + + + 11ty + Eleventy + + + Include + Includes + + + Lexicons + Lexicon + + + manifest.psd1 + PowerShellManifest + + + RemovePart + DeletePart + + + tsconfig.json + TypeScriptConfig.json + + + Underbar + Underscore + + + GetContent + + + + GetTree + + + + Match + + + + Relate + + + + SetContent + + + + Astro + + <# +.SYNOPSIS + Gets Open Package Astro Files +.DESCRIPTION + Gets Astro File Content in an Open Package +#> + +param() + +$this.GetContent($this.FileList -match '\.astro$') + + + + Cache + + <# +.SYNOPSIS + Gets cache +.DESCRIPTION + Gets an open package's cache. + + This is an ordered dictionary of data attached to the object, but not saved to disk. +#> +param() + +if (-not $this) { return } + +if (-not $this.'#Cache') { + Add-Member -InputObject $this NoteProperty '#Cache' ([Ordered]@{}) -Force +} + +return $this.'#Cache' + + + + Category + + <# +.SYNOPSIS + Gets OpenPackage `Category` +.DESCRIPTION + Gets the OpenPackage `Category` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.category?wt.mc_id=MVP_321542 +#> +param() + +$this.PackageProperties.Category + + + <# +.SYNOPSIS + Sets OpenPackage `Category` +.DESCRIPTION + Sets the OpenPackage `Category` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.category?wt.mc_id=MVP_321542 +#> +param([string]$Category) + +$this.PackageProperties.Category = $Category + + + + CHANGELOG.md + + <# +.SYNOPSIS + Gets a package's changelog +.DESCRIPTION + Gets the content of any parts in the package named CHANGELOG.md +#> +[OutputType("text/markdown")] +param() + +# Get every part +foreach ($part in $this.GetParts()) { + # and ignore any part not named CHANGELOG + if ($part.Uri -notmatch '/CHANGELOG\.md$') { continue } + + if ($part.Reader) { + $part.Read() + } else { + $part + } +} + +# We are done. + + + + ChocolateyInstall + + <# +.SYNOPSIS + Gets the Chocolatey Install Script +.DESCRIPTION + Gets the Chocolatey Install Script from an OpenPackage, if one is present. + + The Chocolatey install script must be located at /tools/chocolateyInstall.ps1 +#> +param() +$this.GetContent("/tools/chocolateyInstall.ps1") + + + + Claude.md + + <# +.SYNOPSIS + Gets a package's Claude Markdown +.DESCRIPTION + Gets any `claude.md` or `/.claude/*.md` files in an Open Package +.LINK + https://claude.com/blog/using-claude-md-files +#> +foreach ($part in $this.GetParts()) { + if ($part.Uri -match '/CLAUDE\.(?>md|markdown)$' -or + $part.Uri -match '/\.claude/.+?\.(?>md|markdown)$' + ) { + if ($part.Reader) { + $part.Read() + } else { + $part + } + } +} + + + + CodeOfConduct.md + + <# +.SYNOPSIS + Gets a package's code of conduct +.DESCRIPTION + Gets any parts in the package named Code_Of_Conduct.md +#> +[OutputType("text/markdown")] +param() + +# Get every part +foreach ($part in $this.GetParts()) { + # and ignore any part not named CODE_OF_CONDUCT + if ($part.Uri -notmatch '/CODE_OF_CONDUCT\.md$') { continue } + + if ($part.Reader) { + $part.Read() + } else { + $part + } +} + +# We are done. + + + + Config.json + + <# +.SYNOPSIS + Gets a package's config json +.DESCRIPTION + Gets the objects stored in any config.json files in the package. +.NOTES + config.json files are used by several static site generators. +#> +[OutputType([PSObject])] +param() + +$partPattern = '[/\+_]config\.json$' + +foreach ($part in $this.GetParts()) { + if ($part.Uri -notmatch $partPattern) { continue } + if ($part.Reader) { + $part.Read() + } else { + $part + } +} + + + + + Config.yaml + + <# +.SYNOPSIS + Gets a package's config yaml +.DESCRIPTION + Gets the objects stored in any config.yaml files in the package. +.NOTES + config.yaml files are used by several static site generators. +#> +[OutputType([PSObject])] +param() + +$partPattern = '[/\+_]config\.ya?ml$' + +foreach ($part in $this.GetParts()) { + if ($part.Uri -notmatch $partPattern) { continue } + if ($part.Reader) { + $part.Read() + } else { + $part + } +} + + + + + ContentStatus + + <# +.SYNOPSIS + Gets OpenPackage `ContentStatus` +.DESCRIPTION + Gets the OpenPackage `ContentStatus` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.contentstatus?wt.mc_id=MVP_321542 +#> +param() + +$this.PackageProperties.ContentStatus + + + <# +.SYNOPSIS + Sets OpenPackage `ContentStatus` +.DESCRIPTION + Sets the OpenPackage `ContentStatus` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.contentstatus?wt.mc_id=MVP_321542 +#> +param([string]$ContentStatus) + +$this.PackageProperties.ContentStatus = $ContentStatus + + + + ContentType + + <# +.SYNOPSIS + Gets OpenPackage `ContentType` +.DESCRIPTION + Gets the OpenPackage `ContentType` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.contenttype?wt.mc_id=MVP_321542 +#> +param() + +$this.PackageProperties.ContentType -split ';' + + + <# +.SYNOPSIS + Sets OpenPackage `ContentType` +.DESCRIPTION + Sets the OpenPackage `ContentType` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.contenttype?wt.mc_id=MVP_321542 +#> +param([string]$ContentType) + +$this.PackageProperties.ContentType = $ContentType + + + + Contributing.md + + <# +.SYNOPSIS + Gets a package's contribution guide +.DESCRIPTION + Gets any parts in the package named Contributing.md +#> +[OutputType("text/markdown")] +param() + +# Get every part +foreach ($part in $this.GetParts()) { + # and ignore any part not named Contributing + if ($part.Uri -notmatch '/Contributing\.md$') { continue } + + if ($part.Reader) { + $part.Read() + } else { + $part + } +} + +# We are done. + + + + Count + + <# +.SYNOPSIS + Gets the package files count +.DESCRIPTION + Gets the number of files in a package. +#> +return @($this.GetParts()).Length + + + + Created + + <# +.SYNOPSIS + Gets OpenPackage creation time +.DESCRIPTION + Gets the OpenPackage `Created` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.created?wt.mc_id=MVP_321542 +#> +param() + +$this.PackageProperties.Created + + + <# +.SYNOPSIS + Sets OpenPackage `Created` +.DESCRIPTION + Sets the OpenPackage `Created` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.created?wt.mc_id=MVP_321542 +#> +param([DateTime]$Created) + +$this.PackageProperties.Created = $Created + + + + Creator + + <# +.SYNOPSIS + Gets OpenPackage `Creator` +.DESCRIPTION + Gets the OpenPackage `Creator` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.creator?wt.mc_id=MVP_321542 +#> +param() + +if ($this.PackageProperties.Creator) { + return $this.PackageProperties.Creator +} + +$moduleManifest = @($this.PowerShellManifest)[0] +if ($moduleManifest) { + $this.PackageProperties.Creator = $moduleManifest.Author + return $this.PackageProperties.Creator +} + +$packageJson = @($this.'Package.json')[0] +if ($packageJson -and $packageJson.author) { + $this.PackageProperties.Creator = + if ($packageJson.author -is [string]) { + $packageJson.author + } else { + $packageJson.author | ConvertTo-Json -Depth 3 + } + return $this.PackageProperties.Creator +} + +$nuSpec = @($this.nuSpec)[0] +if ($nuSpec -and $nuSpec.package.metadata.authors) { + $this.PackageProperties.Creator = + $nuSpec.package.metadata.authors + + return $this.PackageProperties.Creator +} + + + <# +.SYNOPSIS + Sets OpenPackage `Creator` +.DESCRIPTION + Sets the OpenPackage `Creator` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.creator?wt.mc_id=MVP_321542 +#> +param([string]$Creator) + +$this.PackageProperties.Creator = $Creator + + + + Description + + return $this.PackageProperties.Description + + + $this.PackageProperties.Description = $args -join [Environment]::NewLine + + + + Dockerfile + + <# +.SYNOPSIS + Gets a package's dockerfile +.DESCRIPTION + Gets the content of any `Dockerfile`s in the package. +#> +[OutputType([string])] +param() + +$partPattern = '[/\.]DockerFile$' + +foreach ($part in $this.GetParts()) { + if ($part.Uri -match $partPattern) { + $part.Read() + } +} + + + + Eleventy + + <# +.SYNOPSIS + Gets a package's eleventy files +.DESCRIPTION + Gets the content of any elevent config files in the package. + + This includes any files named: + * `.eleventy.js` + * `eleventy.config.js` + * `eleventy.config.mjs` + * `eleventy.config.cjs` +.LINK + https://www.11ty.dev/docs/config/ +#> +param() + +$partPattern = '/(?>\.eleventy.js|elventy\.config\.[mc]?js$)' +foreach ($part in $this.GetParts()) { + if ($part.Uri -match $partPattern) { + if ($part.Reader) { + $part.Read() + } else { + $part + } + } +} + + + + + Eponym + + <# +.SYNOPSIS + Gets all Eponyms in a Package +.DESCRIPTION + Gets all Eponyms within an OpenPackage. + + Eponyms are files whose name matches their directory. + + For example: + + `/foo/foo.ps1` is an eponym + `/foo/foo.md` is an eponym + `/foo/bar.ps1` is not an eponym + + If a package has an an identifier, + any files whose name matches this identifier will be considered an eponym. +.NOTES + Multiple eponyms may exist for a given path. + + Anything before the first period `.` will be considered the name of the file. +#> +param() + +if (-not $this.GetParts) { return } +foreach ($part in $this.GetParts()) { + $partSegments = @($part.Uri -split '/+' -ne '') + if ($partSegments.Count -eq 1) { + if ($partSegments[0] -replace '\..+$' -eq $this.Identifier) { + $part + continue + } + } + if ($partSegments.Count -ge 2) { + if ($partSegments[-2] -eq + ($partSegments[-1] -replace '\..+$') + ) { + $part + continue + } + } +} + + + + + FileContentType + + <# +.SYNOPSIS + Gets file content types +.DESCRIPTION + Gets a table of all files in the package and their associated content types. +#> +$fileContentTypes = [Ordered]@{} +foreach ($part in @($this.GetParts())) { + $fileContentTypes[$part.Uri] = $part.ContentType +} +$fileContentTypes + + + + + FileHash + + <# +.SYNOPSIS + Gets the file hashes +.DESCRIPTION + Gets the file hashes of each part using any supported algorithm (default SHA256) +.NOTES + Supports any algorithm from Get-FileHash +.LINK + Get-FileHash +#> +param([string]$Algorithm = 'SHA256') + +foreach ($part in $this.GetParts()) { + $part.GetHash($Algorithm) +} + + + + + FileList + + <# +.SYNOPSIS + Gets OpenPackage file list +.DESCRIPTION + Gets the list of files in an OpenPackage. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.package.getparts?wt.mc_id=MVP_321542 +#> +[OutputType([string[]])] +param() + +@($this.GetParts()).Uri -as [string[]] + + + + FileSize + + <# +.SYNOPSIS + Gets file content types +.DESCRIPTION + Gets a table of all files in the package and their associated content types. +#> +$fileLengths = [Ordered]@{} +foreach ($part in @($this.GetParts())) { + $partStream = $part.GetStream() + $fileLengths[$part.Uri] = $partStream.Length + $partStream.Close() + $partStream.Dispose() +} +$fileLengths + + + + + Function.json + + <# +.SYNOPSIS + Gets a package's `function.json` +.DESCRIPTION + Gets the content of any `function.json` files in the package. + + These files are used by Azure Functions +.LINK + https://github.com/Azure/azure-functions-host/wiki/function.json +#> +[OutputType([PSObject])] +param() + +foreach ($part in $this.GetParts()) { + if ($part.Uri -notmatch '/function\.json$') { continue } + + if ($part.Reader) { + $part.Read() + } else { + $part + } +} + + + + Host.json + + <# +.SYNOPSIS + Gets a package's `host.json` +.DESCRIPTION + Gets the content of any `host.json` files in the package. + + These files are used by Azure Functions +.LINK + https://github.com/Azure/azure-functions-host/wiki/host.json-(v2) +#> +[OutputType([PSObject])] +param() + +foreach ($part in $this.GetParts()) { + if ($part.Uri -notmatch '/host\.json$') { continue } + + if ($part.Reader) { + $part.Read() + } else { + $part + } +} + + + + Identifier + + $this.PackageProperties.Identifier + + + $this.PackageProperties.Identifier = $args -join ' ' + + + + ImageFileList + + <# +.SYNOPSIS + Gets package image files +.DESCRIPTION + Gets the list of image files within a package. +#> +@(foreach ($part in $this.GetParts()) { + if ($part.ContentType -match 'image/' -or + $part.Uri -match '\.(?>a?png|jpe?g|gif|tiff?|svg|ico|bmp|exr)$' + ) { + $part.Uri + } +}) -as [string[]] + + + + ImportMap + + <# +.SYNOPSIS + Gets a package's `importMap.json` +.DESCRIPTION + Gets the content of any `importMap.json` files in the package +#> +[OutputType([psobject])] +param() + + +$this.GetContent(@($this.FileList -match '\importMap\.json$')) + + + + + + Includes + + <# +.SYNOPSIS + Gets Open Package Includes +.DESCRIPTION + Gets any `_includes` files within an Open Package. + + If the include file type has a reader, will read the content. + + If the include file type does not have a reader, will include the part. +.NOTES + Returns all includes as a dictionary. +#> +if (-not $this.GetParts) { return } + +$includes = [Ordered]@{} + +foreach ($part in $this.GetParts()) { + if ($part.Uri -notmatch '/_includes/') { + continue + } + + $includeKey = $part.Uri -replace '.{0,}?/_includes/' + if ($part.Reader) { + $includes[$includeKey] = $part.Read() + } else { + $includes[$includeKey] = $part + } +} + +return $includes + + + + Keywords + + <# +.SYNOPSIS + Gets OpenPackage `Keywords` +.DESCRIPTION + Gets the OpenPackage `Keywords` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.keywords?wt.mc_id=MVP_321542 +#> +param() + +$this.PackageProperties.Keywords -split '[\s\r\n]+' + + + <# +.SYNOPSIS + Sets OpenPackage `Keywords` +.DESCRIPTION + Sets the OpenPackage `Keywords` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.keywords?wt.mc_id=MVP_321542 +#> +param() + +$this.PackageProperties.Keywords = $args -join ' ' + + + + Language + + <# +.SYNOPSIS + Gets OpenPackage Language time +.DESCRIPTION + Gets the OpenPackage `Language` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.language?wt.mc_id=MVP_321542 +#> +param() + +$this.PackageProperties.Language + + + <# +.SYNOPSIS + Sets OpenPackage `Language` +.DESCRIPTION + Sets the OpenPackage `Language` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.language?wt.mc_id=MVP_321542 +#> +param([string]$Language) + +$this.PackageProperties.Language = $Language + + + + LanguagePercent + + <# +.SYNOPSIS + Gets the language percentages of a package +.DESCRIPTION + Gets the language percentages present in the package. +.NOTES + Definitions of what constitutes a language have been quite contentious. + + For the purposes of accurately identifying what lies within a package, we want a very broad definition. + + If you believe a language should be included, file an issue. + + If you believe any given file format is or is not a language, do not file an issue. +#> +$LanguagesByLength = [Ordered]@{} + +$totalLength = 0 +$fileSizes = $this.FileSize +foreach ($part in $this.GetParts()) { + $partLength = $fileSizes[$part.Uri] + + $recognizedLanguage = + switch -regex ($part.Uri) { + '\.3mf$' { '3MF'} + '\.astro' { 'Astro' } + '\.c$' { 'C' } + '\.cast$' { 'Asciiema' } + '\.clixml$' { 'Clixml'} + '\.cjs$' { 'Common JavaScript'} + '\.cpp$' { 'C++' } + '\.cs$' { 'C# '} + '\.csv$' { 'Comma Separated Values' } + '\.csh$' { 'CShell'} + '\.css$' { 'Cascading Stylesheets' } + '(?>/word/.+?\.xml|\.docx?)$' { 'Word '} + '\.dll$' { 'Binary' } + '\.exe$' { 'Binary' } + '\.gif$' { 'GIF' } + '\.go$' { 'Go Language' } + '\.h$' { 'C Header' } + '\.html?$' { 'Hypertext Markup Language' } + '\.java$' {'Java' } + '\.jpe?g$' { 'Joint Pictures Expert Group'} + '\.json$' {'JavaScript Object Notation' } + '\.jsonc$' {'Commented JavaScript Object Notation' } + '\.jsonl$' {'JavaScript Object Notation Lines' } + '\.js$' { 'Javascript'} + '\.jsx$' { 'JavaScript XML'} + '\.(?>md|mdx|markdown)$' { 'Markdown' } + '\.midi?$' { 'MIDI' } + '\.(?>jsm|mjs)$' { 'JavaScript Module'} + '\.mkv$' { 'Matroska Video'} + '\.mka$' { 'Matroska Audio'} + '\.mks$' { 'Matroska Subtitle'} + '\.mk3d$' { 'Matroska Stereoscopic Video'} + '\.mp3$' { 'MP3' } + '\.mp4$' { 'MP4' } + '\.nix$' { 'Nix' } + '\.oog$' { 'OOG' } + '\.pl$' { 'Perl' } + '\.png$' { 'Portable Network Graphics' } + '(?>/ppt/.+?\.xml|\.pptx?)$' { 'PowerPoint'} + '\.psm?1$' { 'PowerShell' } + '\.psd1$' {'PowerShell Data Language' } + '\.ps1xml$' { 'PowerShell Xml' } + '\.py$' { 'Python' } + '\.rs$' { 'Rust '} + '\.rss$' { 'RSS' } + '\.sh$' { 'BourneShell'} + '\.stl$' { 'STL'} + '\.svg$' { 'SVG' } + '\.tar$' { 'Tarfile' } + '(?>\.tar\.gz|\.tgz)$' { 'GZippedTarfile' } + '\.tsx?$' { 'TypeScript' } + '\.tsv$' { 'Tab Separated Values' } + '\.toml$' { 'Tom''s Obvious Minimal Language' } + '\.xhtml$' { 'XHTML' } + '(?>/xl/.+?\.xml|\.xlsx?)$' { 'Excel'} + '\.xsl$' { 'XSL' } + '\.xml$' { 'XML' } + '\.ya?ml$' { 'Yaml' } + '\.zip$' { 'Zip' } + '\.webm' { 'Web Movie' } + '\.weba' { 'Web Audio' } + '\.webp' { 'Web Photo' } + default { 'Unknown' } + } + + if (-not $recognizedLanguage) { + continue + } + + + if (-not $LanguagesByLength[$recognizedLanguage]) { + $LanguagesByLength[$recognizedLanguage] = 0 + } + + $LanguagesByLength[$recognizedLanguage]+=$partLength + + $totalLength += $partLength +} + + +$SortedByLength = [Ordered]@{} + +foreach ($keyValue in $languagesByLength.GetEnumerator() | + Sort-Object Value -Descending +) { + $SortedByLength[$keyValue.Key] = $keyValue.Value / $totalLength +} + +return $SortedByLength + + + + LastModifiedBy + + <# +.SYNOPSIS + Gets OpenPackage LastModifiedBy time +.DESCRIPTION + Gets the OpenPackage `LastModifiedBy` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.lastmodifiedby?wt.mc_id=MVP_321542 +#> +param() + +$this.PackageProperties.LastModifiedBy + + + <# +.SYNOPSIS + Sets OpenPackage `LastModifiedBy` +.DESCRIPTION + Sets the OpenPackage `LastModifiedBy` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.Lastmodifiedby?wt.mc_id=MVP_321542 +#> +param([string]$LastModifiedBy) + +$this.PackageProperties.LastModifiedBy = $LastModifiedBy + + + + LastPrinted + + <# +.SYNOPSIS + Gets OpenPackage LastPrinted time +.DESCRIPTION + Gets the OpenPackage `LastPrinted` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.lastprinted?wt.mc_id=MVP_321542 +#> +param() + +$this.PackageProperties.LastPrinted + + + <# +.SYNOPSIS + Sets OpenPackage `LastPrinted` +.DESCRIPTION + Sets the OpenPackage `LastPrinted` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.lastprinted?wt.mc_id=MVP_321542 +#> +param([DateTime]$LastPrinted) + +$this.PackageProperties.LastPrinted = $LastPrinted + + + + Lexicon + + <# +.SYNOPSIS + Gets any lexicons +.DESCRIPTION + Gets all at protocol lexicons in the package + + A lexicon is defined by the presence of three keys: + + * `id` + * `lexicon` + * `defs + + The output will be a table mapping ids to contents. +#> +[OutputType([Ordered])] +param() + +$allLexicons = [Ordered]@{} + +# Get every part +foreach ($part in $this.GetParts()) { + # and ignore any part that is not json. + if ($part.Uri -notmatch '\.json$') { continue } + # Also ignore any package-lock files + if ($part.Uri -match 'package-lock') { continue } + + # If there is no reader, continue + if (-not $part.Reader) { continue } + + try { + # Try to read our data + $partData = $part.Read() + + # ignore any arrays + if ($partData -is [Object[]]) { continue } + + # If the data has a .lexicon, .id, and .defs + if ($partData.lexicon -and + $partData.id -and + $partData.defs + ) { + # store it in our lexicons table. + $allLexicons[$partData.id] = $partData + } + } catch { + Write-Warning "$($part.Uri) read error $($_)" + continue + } +} +# return all of the lexicons we found. +return $allLexicons + + + + Manifest.json + + <# +.SYNOPSIS + Gets a package's `manifest.json` +.DESCRIPTION + Gets the content of any `manifest.json` files in the package +#> +param() + +# Get every part +foreach ($part in $this.GetParts()) { + # and ignore any part not named manifest.json + if ($part.Uri -notmatch '/manifest\.json$') { continue } + + if ($part.Reader) { + $part.Read() + } else { + $part + } +} + +# We are done. + + + + Mcp.json + + <# +.SYNOPSIS + Gets a Package's mcp.json +.DESCRIPTION + Gets any mcp definitions in an Open Package + + Definitions can be in parts matching: + * `/mcp.json` + * `/claude_desktop_config.json` + * `/\.?mcp/server.json` +#> +param() + +$pattern = @( + "/mcp\.json" + "/claude_desktop_config\.json" + '/\.?mcp/server.json$' +) + +$pattern = "(?>$($pattern -join '|'))$" + +foreach ($part in $this.GetParts()) { + if ($part.Uri -notmatch $pattern) { + continue + } + + if ($part.Reader) { + try { + $part.Read() + } catch { + Write-Warning "Error reading $($part.Uri) : $_" + } + } else { + $part + } +} + + + + + Modelfile + + <# +.SYNOPSIS + Gets any Modelfiles in a package +.DESCRIPTION + Gets any Ollama model files within a package +.LINK + https://docs.ollama.com/modelfile +#> +[OutputType([string])] +param() + +$partPattern = '[/\.]Modelfile$' + +foreach ($part in $this.GetParts()) { + if ($part.Uri -match $partPattern) { + $part.Read() + } +} + + + + Modified + + <# +.SYNOPSIS + Gets OpenPackage modified time +.DESCRIPTION + Gets the OpenPackage `Modified` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.modified?wt.mc_id=MVP_321542 +#> +param() + +$this.PackageProperties.Modified + + + <# +.SYNOPSIS + Sets OpenPackage `Modified` +.DESCRIPTION + Sets the OpenPackage `Modified` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.modified?wt.mc_id=MVP_321542 +#> +param([DateTime]$Modified) + +$this.PackageProperties.Modifiedd = $Modified + + + + Nix + + <# +.SYNOPSIS + Gets a package's `*.nix` files +.DESCRIPTION + Gets the content of any `*.nix` files in the package. +#> +[OutputType([string])] +param() + +$this.GetContent(@($this.FileList) -match '\.nix$') + + + + + Nuspec + + <# +.SYNOPSIS + Gets a package's nuspec +.DESCRIPTION + Gets the content of any `*.nuspec` files in the package +#> +[OutputType([xml])] +param() + +$this.GetContent( + $this.FileList -match '\.nuspec$' +) + + + + + Package.json + + <# +.SYNOPSIS + Gets a package's `package.json` +.DESCRIPTION + Gets the content of any `package.json` files in the package +#> +[OutputType([PSObject])] +param() + +# Get every part +foreach ($part in $this.GetParts()) { + # and ignore any part not named CHANGELOG + if ($part.Uri -notmatch '/package\.json$') { continue } + + if ($part.Reader) { + $part.Read() + } else { + $part + } +} + +# We are done. + + + + Palette + + <# +.SYNOPSIS + Gets an Open Package palette +.DESCRIPTION + Gets an Open Package's palette. +.NOTES + A palette is a relationship between a package and a stylesheet. +#> +param() + +if (-not $this.RelationshipExists -or + -not $this.GetRelationship) { + return +} + +if ($this.RelationshipExists('palette')) { + return $this.GetRelationship('palette').TargetUri +} + + + <# +.SYNOPSIS + Sets an Open Package palette +.DESCRIPTION + Sets an Open Package's palette. +.NOTES + A palette is a relationship between a package and a stylesheet. +#> +param( +[Parameter(Mandatory)] +[ValidatePattern('.css$')] +[uri]$Palette +) +if (-not $this.RelationshipExists) { + return +} +if ($this.RelationshipExists('palette')) { + $this.DeleteRelationship('palette') + $this.CreateRelationship($Palette, 'external', 'stylesheet', 'palette') +} else { + $this.CreateRelationship($Palette, 'external', 'stylesheet', 'palette') +} + + + + + + Parts + + <# +.SYNOPSIS + Gets Package Parts +.DESCRIPTION + Gets Open Package Parts. +.NOTES + This is a shorthand for the method `GetParts()` +#> +if (-not $this.GetParts) { + return +} + +@($this.GetParts()) + + + + + PowerShellCommandAst + + <# +.SYNOPSIS + Gets PowerShell Command References +.DESCRIPTION + Gets PowerShell Command Ast references within an Open Package. +#> +foreach ($content in $this.GetContent(@($this.FileList -match '.psm?1$'))) { + if (-not $content.Ast) { continue } + $content.Ast.FindAll({ + param($ast) + + $ast -is [Management.Automation.Language.CommandAst] + }, $true) | + Add-Member NoteProperty PartUri $content.PartUri -Force -PassThru | + Add-Member NoteProperty Package $content.Package -Force -PassThru +} + + + + PowerShellFunctionAst + + <# +.SYNOPSIS + Gets PowerShell Function Defintions +.DESCRIPTION + Gets PowerShell Function Defintion Ast references within an Open Package. +#> +foreach ($content in $this.GetContent(@($this.FileList -match '.psm?1$'))) { + if (-not $content.Ast) { continue } + $content.Ast.FindAll({ + param($ast) + + $ast -is [Management.Automation.Language.FunctionDefinitionAst] + }, $true) | + Add-Member NoteProperty PartUri $content.PartUri -Force -PassThru | + Add-Member NoteProperty Package $content.Package -Force -PassThru +} + + + + PowerShellManifest + + <# +.SYNOPSIS + Gets a package's PowerShell manifest files +.DESCRIPTION + Gets a package's PowerShell manifest files. + + These are any `*.psd1` files in the package that: + + * Are valid PowerShell data blocks + * Contain a ModuleVersion +#> +[OutputType([PSObject])] +param() + +foreach ($part in $this.GetParts()) { + if ($part.Uri -notmatch '\.psd1$') { continue } + try { + $psd1 = $part.Read() + if (-not $psd1.ModuleVersion) { continue } + $psd1 + } catch { + Write-Debug "Could not read $($part.Uri): $_" + } +} + + + + PowerShellParameterAst + + <# +.SYNOPSIS + Gets PowerShell Parameter Definitions +.DESCRIPTION + Gets all PowerShell ParameterAst references within an Open Package. +#> +foreach ($content in $this.GetContent(@($this.FileList -match '.psm?1$'))) { + if (-not $content.Ast) { continue } + $content.Ast.FindAll({ + param($ast) + + $ast -is [Management.Automation.Language.ParameterAst] + }, $true) | + Add-Member NoteProperty PartUri $content.PartUri -Force -PassThru | + Add-Member NoteProperty Package $content.Package -Force -PassThru +} + + + + PowerShellTypeAst + + <# +.SYNOPSIS + Gets PowerShell Type References +.DESCRIPTION + Gets PowerShell TypeExpressionAst references within an Open Package. +#> +foreach ($content in $this.GetContent(@($this.FileList -match '.psm?1$'))) { + if (-not $content.Ast) { continue } + $content.Ast.FindAll({ + param($ast) + + $ast -is [Management.Automation.Language.TypeExpressionAst] + }, $true) | + Add-Member NoteProperty PartUri $content.PartUri -Force -PassThru | + Add-Member NoteProperty Package $content.Package -Force -PassThru +} + + + + PowerShellTypeDefinitionAst + + <# +.SYNOPSIS + Gets PowerShell Type Defintions +.DESCRIPTION + Gets PowerShell Type Defintion Ast references within an Open Package. +#> +foreach ($content in $this.GetContent(@($this.FileList -match '.psm?1$'))) { + if (-not $content.Ast) { continue } + $content.Ast.FindAll({ + param($ast) + + $ast -is [Management.Automation.Language.TypeDefinitionAst] + }, $true) | + Add-Member NoteProperty PartUri $content.PartUri -Force -PassThru | + Add-Member NoteProperty Package $content.Package -Force -PassThru +} + + + + PowerShellUniversal + + <# +.SYNOPSIS + Gets PowerShell Universal Files in a package +.DESCRIPTION + Gets PowerShell Universal Files in an open package +#> + +foreach ($part in $this.GetParts()) { + if ($part.Uri -notmatch '/\.universal/') { continue } + if ($part.Uri -notmatch '\.ps1$') { continue } + $part +} + + + + ProjectFile + + <# +.SYNOPSIS + Gets a package's project files +.DESCRIPTION + Gets the content of any `*.*proj` files in the package +#> +[OutputType([xml])] +param() + +$this.GetContent( + $this.FileList -match '\..+?proj$' +) + + + + README.md + + <# +.SYNOPSIS + Gets a package's README +.DESCRIPTION + Gets the content of any parts in the package named README.md +#> +[OutputType("text/markdown")] +param() + +# Get all Readme files +foreach ($content in $this.GetContent( + $this.FileList -match '/README\.md$' +)) { + # decorate them as text/markdown + if ($content.pstypenames -notcontains 'text/markdown') { + $content.pstypenames.insert(0, 'text/markdown') + } + # and return them. + $content +} + +return + + + + Related + + <# +.SYNOPSIS + Get Related Package information +.DESCRIPTION + Get Package Relationships +.NOTES + This is a shorthand for `.GetRelationships()` +#> +if (-not $this.GetRelationships) { + return +} +@($this.GetRelationships()) + + + + + Reptile + + <# +.SYNOPSIS + Gets Open Package Reptiles +.DESCRIPTION + Gets Reptile files in Open Packages +.LINK + https://github.com/PoshWeb/Reptile +#> +foreach ($part in $this.GetParts()) { + if ($part.Uri -notmatch '\.reptile\.ps1$') { continue } + $part +} + + + + Revision + + <# +.SYNOPSIS + Gets OpenPackage `Revision` +.DESCRIPTION + Gets the OpenPackage `Revision` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.revision?wt.mc_id=MVP_321542 +#> +param() + +$this.PackageProperties.Revision + + + <# +.SYNOPSIS + Sets OpenPackage `Revision` +.DESCRIPTION + Sets the OpenPackage `Revision` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.revision?wt.mc_id=MVP_321542 +#> +param([string]$Revision) + +$this.PackageProperties.Revision = $Revision + + + + Security.md + + <# +.SYNOPSIS + Gets a package's security notice +.DESCRIPTION + Gets any parts in the package named Security.md +#> +[OutputType("text/markdown")] +param() + +# Get every part +foreach ($part in $this.GetParts()) { + # and ignore any part not named SECURITY + if ($part.Uri -notmatch '/SECURITY\.md$') { continue } + + if ($part.Reader) { + $part.Read() + } else { + $part + } +} + +# We are done. + + + + ServiceWorker.js + + <# +.SYNOPSIS + Gets any service workers in a package +.DESCRIPTION + Gets any clearly named service workers in a package. + + Will find any files named `sw.js` or `ServiceWorker.js` +#> +param() + +$pattern = '/(?>sw|ServiceWorker).js$' + +foreach ($part in $this.GetParts()) { + if ($part.Uri -match $pattern) { + if ($part.Reader) { + $part.Read() + } else { + $part + } + } +} + +# We are done. + + + + Subject + + <# +.SYNOPSIS + Gets OpenPackage `Subject` +.DESCRIPTION + Gets the OpenPackage `Subject` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.subject?wt.mc_id=MVP_321542 +#> +param() + +$this.PackageProperties.Subject + + + <# +.SYNOPSIS + Sets OpenPackage `Subject` +.DESCRIPTION + Sets the OpenPackage `Subject` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.subject?wt.mc_id=MVP_321542 +#> +param([string]$Subject) + +$this.PackageProperties.Subject = $Subject + + + + Template.json + + <# +.SYNOPSIS + Gets a package's `template.json` +.DESCRIPTION + Gets the content of any `template.json` files in the package +#> +[OutputType([PSObject])] +param() + +foreach ($part in $this.GetParts()) { + if ($part.Uri -notmatch '/template\.json$') { continue } + + if ($part.Reader) { + $part.Read() + } else { + $part + } +} + + + + Title + + <# +.SYNOPSIS + Gets OpenPackage `Title` +.DESCRIPTION + Gets the OpenPackage `Title` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.title?wt.mc_id=MVP_321542 +#> +param() + +$this.PackageProperties.Title + + + <# +.SYNOPSIS + Sets OpenPackage `Title` +.DESCRIPTION + Sets the OpenPackage `Title` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.title?wt.mc_id=MVP_321542 +#> +param([string]$Title) + +$this.PackageProperties.Title = $Title + + + + TypeScriptConfig.json + + <# +.SYNOPSIS + Gets a package's `tsconfig.json` +.DESCRIPTION + Gets the content of any TypeScript configuration `tsconfig.json` files in the package +#> +[OutputType([psobject])] +param() + +# Get every part +foreach ($part in $this.GetParts()) { + # and ignore any part not named manifest.json + if ($part.Uri -notmatch '/tsconfig\.json$') { continue } + + if ($part.Reader) { + $part.Read() + } else { + $part + } +} + +# We are done. + + + + Underscore + + <# +.SYNOPSIS + Gets package underscore files +.DESCRIPTION + Gets underscore files defined in a package. + + These are any files that contain an underscore `_` in their path. + + This returns a series of dictionaries containing the contents of the package +#> +param() + + +return $this.GetTree("/_") + + + + + Version + + <# +.SYNOPSIS + Gets OpenPackage `Version` +.DESCRIPTION + Gets the OpenPackage `Version` property. + + If this has not been explicitly set, looks for potential version information in: + + * PowerShell Manifests + * package.json + * nuspec files +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.version?wt.mc_id=MVP_321542 +#> +param() + +if ($this.PackageProperties.Version) { + return $this.PackageProperties.Version +} + +$moduleManifest = @($this.PowerShellManifest)[0] +if ($moduleManifest) { + $this.PackageProperties.Version = $moduleManifest.ModuleVersion + return $this.PackageProperties.Version +} + +$packageJson = @($this.'Package.json')[0] +if ($packageJson -and $packageJson.version) { + $this.PackageProperties.Version = $packageJson.version + return $this.PackageProperties.Version +} + +$nuSpec = @($this.nuSpec)[0] +if ($nuSpec -and $nuSpec.package.metadata.version) { + $this.PackageProperties.Version = + $nuSpec.package.metadata.version + + return $this.PackageProperties.Version +} + + + <# +.SYNOPSIS + Sets OpenPackage `Version` +.DESCRIPTION + Sets the OpenPackage `Version` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.version?wt.mc_id=MVP_321542 +#> +param([string]$Version) + +$this.PackageProperties.Version = $Version + + + + VsixManifest + + <# +.SYNOPSIS + Gets VsixManfiests from a package +.DESCRIPTION + Gets any Visual Studio Extension Manifests (*.vsixManifest) files in a package. +#> +$partPattern = '\.vsixManifest$' + +$this.GetContent(@( + $this.FileList -match $partPattern +)) + + + + + XRPC + + <# +.SYNOPSIS + Gets package xrpc +.DESCRIPTION + Gets any xrpc parts within the package. +#> +if (-not $this.GetParts) { return } +foreach ($part in $this.GetParts()) { + if ( + $part.Uri -notmatch '/xrpc' -or + $part.Uri -notlike '*.*.*' + ) { + continue + } + $part +} + + + + DefaultDisplay + Identifier +FileList + + + README + # Open Package + +Anything can become a package. + +## Open Packaging Conventions + +The [Open Package Conventions](https://en.wikipedia.org/wiki/Open_Packaging_Conventions) are an Open Protocol for Open Packages. + +They were first published in 2006, in [ECMA-376](https://ecma-international.org/publications-and-standards/standards/ecma-376/) + +Like many file formats, Open Packages are zip files in a trenchcoat. + +In PowerShell we can work with Open Packages using .NET type [`System.IO.Packaging.Package`](https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties?view=windowsdesktop-10.0&wt.mc_id=MVP_321542) + +In PowerShell, we can also extend type data using either Update-TypeData or a .types.ps1xml file. + +This is how OP works. + +It extends what you can do with OpenPackages in PowerShell, uses Open Protocols to read and write to packages. + +There are _quite_ a lot of things you can do with an Open Package, and we will add more with time. + +To explore what you can do with an open package, use the PowerShell command `Get-Member`: + +~~~PowerShell +# Get an empty package and get its methods and properties. +Get-OpenPackage | Get-Member +~~~ + + + + + OpenPackage.ContentTypeMap + + + PSStandardMembers + + + DefaultDisplayPropertySet + + TypeMap + + + + + + DefaultTypeMap + + data { @{ + '.aac' = 'audio/aac' + '.abw' = 'application/x-abiword' + '.apng' = 'image/apng' + '.arc' = 'application/x-freearc' + '.avif' = 'image/avif' + '.avi' = 'video/x-msvideo' + '.azw' = 'application/vnd.amazon.ebook' + '.bin' = 'application/octet-stream' + '.bmp' = 'image/bmp' + '.bz' = 'application/x-bzip' + '.bz2' = 'application/x-bzip2' + '.c' = 'text/plain' + '.cda' = 'application/x-cdf' + '.cpp' = 'text/plain' + '.cs' = 'text/plain' + '.csh' = 'application/x-csh' + '.csproj' = 'application/xml' + '.css' = 'text/css' + '.csv' = 'text/csv' + '.doc' = 'application/msword' + '.docx' = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + '.eot' = 'application/vnd.ms-fontobject' + '.epub' = 'application/epub+zip' + '.exe' = 'application/executeable' + '.fsproj' = 'application/xml' + '.gz' = 'application/gzip' + '.go' = 'text/plain' + '.gif' = 'image/gif' + '.h' = 'text/plain' + '.htm' = 'text/html' + '.html' = 'text/html' + '.ico' = 'image/vnd.microsoft.icon' + '.ics' = 'text/calendar' + '.ini' = 'text/plain' + '.jar' = 'application/java-archive' + '.jpeg' = 'image/jpeg' + '.jpg' = 'image/jpeg' + '.js' = 'text/javascript' + '.jsm' = 'text/javascript' + '.json' = 'application/json' + '.jsonld' = 'application/ld+json' + '.jsx' = 'text/jsx' + '.md' = 'text/markdown' + '.mdx' = 'text/mdx' + '.mod' = 'text/plain' + '.mid' = 'audio/midi' + '.midi' = 'audio/midi' + '.mjs' = 'text/javascript' + '.mp3' = 'audio/mpeg' + '.mp4' = 'video/mp4' + '.mpeg' = 'video/mpeg' + '.mpkg' = 'application/vnd.apple.installer+xml' + '.nuspec' = 'application/xml' + '.op' = 'application/zip' + '.odp' = 'application/vnd.oasis.opendocument.presentation' + '.ods' = 'application/vnd.oasis.opendocument.spreadsheet' + '.odt' = 'application/vnd.oasis.opendocument.text' + '.oga' = 'audio/ogg' + '.ogv' = 'video/ogg' + '.ogx' = 'application/ogg' + '.opus' = 'audio/ogg' + '.otf' = 'font/otf' + '.png' = 'image/png' + '.pdf' = 'application/pdf' + '.php' = 'application/x-httpd-php' + '.ps1' = 'text/x-powershell' + '.ps1xml' = 'text/x-powershell+xml' + '.psm1' = 'text/x-powershell' + '.psd1' = 'text/x-powershell-data' + '.ppt' = 'application/vnd.ms-powerpoint' + '.pptx' = 'application/vnd.openxmlformats-officedocument.presentationml.presentation' + '.rar' = 'application/vnd.rar' + '.rtf' = 'application/rtf' + '.rs' = 'text/plain' + '.s' = 'text/plain' + '.sh' = 'application/x-sh' + '.sql' = 'application/sql' + '.srt' = 'application/x-subrip' + '.svg' = 'image/svg+xml' + '.tar' = 'application/x-tar' + '.tif' = 'image/tiff' + '.tiff' = 'image/tiff' + '.ts' = 'application/x-typescript' + '.ttf' = 'font/ttf' + '.ttl' = 'text/turtle' + '.txt' = 'text/plain' + '.url' = 'text/plain' + '.vb' = 'text/plain' + '.vbproj' = 'application/xml' + '.vbs' = 'text/plain' + '.vsd' = 'application/vnd.visio' + '.vtt' = 'text/vtt' + '.wav' = 'audio/wav' + '.weba' = 'audio/webm' + '.webm' = 'video/webm' + '.webp' = 'image/webp' + '.webmanifest' = 'application/manifest+json' + '.woff' = 'font/woff' + '.woff2' = 'font/woff2' + '.xaml' = 'application/xaml+xml' + '.xhtml' = 'application/xhtml+xml' + '.xls' = 'application/vnd.ms-excel' + '.xlsx' = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + '.xml' = 'application/xml' + '.xsd' = 'text/xsd' + '.xsl' = 'text/xsl' + '.xul' = 'application/vnd.mozilla.xul+xml' + '.zip' = 'application/zip' + '.3gp' = 'video/3gpp' + '.3g2' = 'video/3gpp2' + '.7z' = 'application/x-7z-compressed' +} } + + + + TypeMap + + <# +.SYNOPSIS + Gets the TypeMap +.DESCRIPTION + Gets the TypeMap. + + Will default to the values in `DefaultTypeMap` +#> +if ($this.'#TypeMap') { + return $this.'#TypeMap' +} +$typeMap = [Ordered]@{} +$defaultTypeMap = $this.DefaultTypeMap +foreach ($extension in ($defaultTypeMap.Keys | Sort-Object)) { + $typeMap[$extension] = $defaultTypeMap[$extension] +} +$this | Add-Member NoteProperty $typeMap '#TypeMap' -Force +return $typeMap + + + <# +.SYNOPSIS + Sets the TypeMap +.DESCRIPTION + Sets the TypeMap. + + If an empty dictionary is provided, will clear the typemap. + + Any other values provided will override the existing content type map. +#> +param( +[Collections.IDictionary] +$TypeMap +) + +$thisTypeMap = $this.TypeMap + +if ($TypeMap.Count -eq 0) { + $thisTypeMap.Clear() +} else { + foreach ($key in $TypeMap.Keys) { + $thisTypeMap[$key] = $TypeMap[$key] + } +} + + + + DefaultDisplay + TypeMap + + + README + ## OpenPackage.ContentTypeMap + +Packages can contain anything, but they do not always use consistent content types. + +In order to properly classify content, we need to map any file name into the right content type. + +While there is a wonderfully long list of content types at the +[IANA](https://www.iana.org/assignments/media-types/media-types.xhtml), it does not contain any file extensions. + +[MDN has a list of common types](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/MIME_types/Common_types). + +Sadly, this is far from complete. + +So, in order to make it easy to work with any type of content, OP contains this pseudo type, which gives us a customizable type map. + +## How This Works + +Various package commands will accept a typemap parameter, and default it to this object. + +Those commands will then use this typemap whenever they need to determine the content type of a file. + +For `Get-OpenPackage`, this will inform the content type used to pack the file. + +For `Start-OpenPackage`, this will inform the content type used to serve the part content. + +### OpenPackage.ContentTypeMap.DefaultTypeMap + +The Default Type Map is a PowerShell data file containing a mapping of known extensions to content types. + +### OpenPackage.ContentTypeMap.get_TypeMap + +When first requested, this creates a copy of the DefaultTypeMap. + +Subsequent requests will return this copy. + +### OpenPackage.ContentTypeMap.set_TypeMap + +When provided a dictionary mapping extension to content type, will change the content type map. + + + + + + System.IO.Packaging.PackagePart + + + PSStandardMembers + + + DefaultDisplayPropertySet + + Uri + ContentType + Reader + + + + + + Get + Read + + + GitDates + GitDate + + + Set + Write + + + Export + + + + GetHash + + + + Import + + + + Read + + + + ReadCliXml + + + + ReadCSharp + + + + ReadFormData + + + + ReadJson + + + + ReadJsonL + + + + ReadMarkdown + + + + ReadPowerShell + + + + ReadPowerShellData + + + + ReadText + + + + ReadToml + + + + ReadXml + + + + ReadXsd + + + + ReadXslt + + + + ReadYaml + + + + Relate + + + + Write + + + + WriteClixml + + + + WriteJson + + + + WriteJsonL + + + + WritePowerShell + + + + WriteText + + + + WriteToml + + + + WriteXml + + + + WriteYaml + + + + Culture + + <# +.SYNOPSIS + Gets the culture of the part. +.DESCRIPTION + Gets the `[CultureInfo]` associated with a package part. +#> +param() +# Get all the culture names +$allCultures = [cultureinfo]::GetCultures('all').name + +# Split the uri into segments +foreach ($segment in $this.Uri -split '/' -ne '') { + # if any segment is a culture name + if ($segment -in $allCultures) { + $segment -as [cultureinfo] + # it applies to that segment. + } +} + + + + + Extension + + <# +.SYNOPSIS + Gets a part extensions +.DESCRIPTION + Gets the file extension of a package part. + + This is anything after the last `.` in the part uri. +#> +param() + +# If there is no uri, there is no extension +if (-not $this.Uri) { return } + +# Split by periods, ignore any blanks, and return the last segment. +@($this.Uri -split '\.' -ne '')[-1] + + + + GitDate + + <# +.SYNOPSIS + Gets Package Part Git Dates +.DESCRIPTION + Gets any Git commit Dates associated with a package part. + + This must be run from within a git repository, + and the part source file must exist in the repository. +.NOTES + The package must have two relationships for this to work: + + 1. A `git` `repository`, to indicate the git repository + 2. A `source` `directory`, to indicate the source directory. +#> + +if (-not $this.Package.RelationshipExists) { return } +$repoExists = $this.Package.RelationshipExists('repository') +if (-not $repoExists) { return } + +$sourceExists = $this.Package.RelationshipExists('directory') +if (-not $sourceExists) { return } + +$gitApp = $executionContext.SessionState.InvokeCommand.GetCommand('git', 'application') + +if (-not $gitApp) { return } + +$gitRoot = try { & $gitApp rev-parse --show-toplevel *&>1} catch {$LASTEXITCODE = 0} + +$directory = Get-Item -LiteralPath $gitRoot -ErrorAction Ignore +if (-not $directory) { return } + +$filePath = Join-Path $directory $this.Uri +$file = Get-Item -LiteralPath $filePath -ErrorAction Ignore + +if (-not $file) { return } + +@(& $gitApp log --follow --format=%ci --date default $file.FullName *>&1) -as [datetime[]] + + + + + Hash + + <# +.SYNOPSIS + Gets the part hash +.DESCRIPTION + Gets the part hash as a string +#> +$this.GetHash().Hash + + + + IsEponym + + <# +.SYNOPSIS + Determines if a part is an eponym +.DESCRIPTION + Eponyms are files whose name matches their directory. + + For example: + + `/foo/foo.ps1` is an eponym + `/foo/foo.md` is an eponym + `/foo/bar.ps1` is not an eponym + + If a package has an an identifier, + any files whose name matches this identifier will be considered an eponym. +.NOTES + Multiple eponyms may exist for a given path. + + Anything before the first period `.` will be considered the name of the file. +#> +param() +$part = $this + +$partSegments = @($part.Uri -split '/+' -ne '') + +if ($partSegments.Count -eq 1 -and $this.Package.Identifier) { + if ($partSegments[0] -replace '\..+$' -eq $this.Package.Identifier) { + return $true + } +} + +if ($partSegments.Count -ge 2) { + if ($partSegments[-2] -eq + ($partSegments[-1] -replace '\..+$') + ) { + return $true + } +} + +return $false + + + + Metadata + + <# +.SYNOPSIS + Gets Part Metadata +.DESCRIPTION + Gets Part Metadata from parts with similar names +.EXAMPLE + $package.GetPart("/foo").Metadata +.NOTES + Parts can get metadata from many places. + + This will get metadata in a cascade. + + First, it will get any metadata found in a related data file: + + For example, `/foo/bar.md` could have metadata in: + + * `/foo/bar.md.json` + * `/foo/bar.md.psd1` + * `/foo/bar.md.toml` + * `/foo/bar.md.xml` + * `/foo/bar.md.yaml` + + * `/foo.json` + * `/foo.psd1` + * `/foo.toml` + * `/foo.xml` + * `/foo.yaml` + +#> +param() + +# For the moment, we will limit the types of files we can process as metadata. +$dataFilePattern = '\.(?>json|psd1|toml|xml|ya?ml)$' + +# First, match any directly related files. +$directlyRelated = "^$([Regex]::Escape("$($this.Uri)"))$dataFilePattern" + +# Check each part in the package +foreach ($part in $this.Package.Parts) { + # if it is directly related, and readable, read it. + if ($part.Uri -notmatch $directlyRelated) { continue } + if ($part.Reader) { + try { + $part.Read() + } catch { + Write-Warning "Error Reading $($part.Uri): $_" + } + } +} + +# Now let's go up thru the list of segments +$segments = @($this.Uri -split '/' -ne '') +# Go backwards to forwards (bottom-most path to topmost path) +for ($segmentNumber = $segments.Count - 1; $segmentNumber -ge 0; $segmentNumber--) { + # If we're at the topmost path and have an identifier + $relatedToParent = if ($segmentNumber -eq 0 -and $this.Package.Identifier) { + # Look for files related to the identifier. + "/$([Regex]::Escape($this.Package.Identifier))$dataFilePattern" + } elseif ($segmentNumber -gt 0) { + # If we're at any greater index, join all segments together + "/$([Regex]::Escape( + @( + $segments[0..$segmentNumber]; + # and repeat the last segment. + $segments[$segmentNumber] + ) -join '/' + ))$dataFilePattern$" + } + + # Now go thru all of the parts in the package + foreach ($part in $this.Package.Parts) { + # and find any related to this level of parent. + if ($part.Uri -match $relatedToParent -and $part.Reader) { + try { + # and read the metadata. + $part.Read() + } catch { + Write-Warning "Error Reading $($part.Uri): $_" + } + } + } +} + + + + Name + + <# +.SYNOPSIS + Gets the part name +.DESCRIPTION + Gets the part name. + + This is is anything before the first period (`.`). +#> +param() +@($this.Uri -split '/' -ne '')[-1] -replace '\..+?$' + + + + Palette + + <# +.SYNOPSIS + Gets an Open Package Part's palette +.DESCRIPTION + Gets an Open Package palette for a part +.NOTES + A palette is a relationship between a package and a stylesheet. +#> +param() + +if (-not $this.RelationshipExists -or + -not $this.GetRelationship) { + return +} + +if ($this.RelationshipExists('palette')) { + return $this.GetRelationship('palette').TargetUri +} + + + <# +.SYNOPSIS + Sets an Open Package Part's palette +.DESCRIPTION + Sets an Open Package palette for a part. +.NOTES + A palette is a relationship between a package and a stylesheet. +#> +param( +[Parameter(Mandatory)] +[ValidatePattern('.css$')] +[uri]$Palette +) +if (-not $this.RelationshipExists) { + return +} +if ($this.RelationshipExists('palette')) { + $this.DeleteRelationship('palette') + $this.CreateRelationship($Palette, 'external', 'stylesheet', 'palette') +} else { + $this.CreateRelationship($Palette, 'external', 'stylesheet', 'palette') +} + + + + + + Reader + + <# +.SYNOPSIS + Gets a Part's available Readers +.DESCRIPTION + Gets the different methods we can use to Read a part. +#> +param() + +# If we already have a cached list of Readers +if ($this.'#Readers') { + # return it. + return $this.'#Readers' +} + +# To get all of our methods, go over each method on this object +$ReadMethods = @(:nextMethod foreach ($method in $this.PSObject.Methods) { + # and ignore any methods that do not start with `Read`. + if ($method.Name -notmatch 'Read.+?') { + continue + } + # If there is no script, ignore the method. + if (-not $method.Script) { continue } + $script = $method.Script + # If there are no attributes, ignore the method + if (-not $script.Attributes) { continue } + # Gather all of our attribute data + $attributeData = [Ordered]@{} + :nextAttribute foreach ($attribute in $script.Attributes) { + # If the attribute does not have a key and value, + if (-not ($attribute.Key -and $attribute.value)) { + continue nextAttribute # continue to the next attribute + } + + # If we do not already know about this key + if (-not $attributeData[$attribute.key]) { + # set it + $attributeData[$attribute.key] = $attribute.value + } else { + # otherwise, make our data a list and add the new value + $attributeData[$attribute.key] = @( + $attributeData[$attribute.key] + ) + $attribute.Value + } + } + + # Now we want to try to match. + try { + # If either the `FilePattern` or `ContentTypePattern` matches + # we consider this to be a potential Reader. + + # Loop thru the file patterns + foreach ($pattern in $attributeData.FilePattern) { + # skip parts that do not match. + if ($this.Uri -notmatch $pattern) { continue } + # Output the matching method + $method + continue nextMethod # and continue to the next method + } + # Loop thru the content type patterns + foreach ($pattern in $attributeData.ContentTypePattern) { + # skip parts that do not match. + if ($this.ContentType -notmatch $pattern) { continue } + # Output the matching method + $method + continue nextMethod # and continue to the next method. + } + } catch { + Write-Debug "$_" + } +}) + +# Now that we know all of the methods, we need to put them in order. +$ReadMethods = @( + $ReadMethods | # We can do this with a dynamic sort. + Sort-Object { + $in = $_ + # Simply walk over each attribute + foreach ($attr in $in.Script.Attributes) { + # any named `Order` with an integer value + if ($attr.Key -eq 'Order' -and $attr.Value -as [int]) { + # will use that value + return ($attr.Value -as [int]) + } + } + # Without an order attribute, use zero + return 0 + }, Name # and perform a secondary sort by name. +) + +# Now that we've gotten our Readers and sorted them +# cache them on the object. +$this | Add-Member NoteProperty '#Readers' $ReadMethods -Force +# and return the cached value. +return $this.'#Readers' + + + + Related + + <# +.SYNOPSIS + Get Related Part information +.DESCRIPTION + Get Package Part and Package Relationships +.NOTES + This will`.GetRelationships()` from the current part, + then `.GetRelationships()` from the package. +#> +if (-not $this.GetRelationships) { + return +} + +@( + $this.GetRelationships() + if ($this.Package -is [IO.Packaging.Package]) { + $this.Package.GetRelationships() + } +) + + + + + Writer + + <# +.SYNOPSIS + Gets a Part's available writers +.DESCRIPTION + Gets the different methods we can use to Write to a part. +#> +param() + +# If we already have a cached list of writers +if ($this.'#Writers') { + # return it. + return $this.'#Writers' +} + +# To get all of our methods, go over each method on this object +$WriteMethods = @(:nextMethod foreach ($method in $this.PSObject.Methods) { + # and ignore any methods that do not start with `Write`. + if ($method.Name -notmatch 'Write.+?') { + continue + } + # If there is no script, ignore the method. + if (-not $method.Script) { continue } + $script = $method.Script + # If there are no attributes, ignore the method + if (-not $script.Attributes) { continue } + # Gather all of our attribute data + $attributeData = [Ordered]@{} + :nextAttribute foreach ($attribute in $script.Attributes) { + # If the attribute does not have a key and value, + if (-not ($attribute.Key -and $attribute.value)) { + continue nextAttribute # continue to the next attribute + } + + # If we do not already know about this key + if (-not $attributeData[$attribute.key]) { + # set it + $attributeData[$attribute.key] = $attribute.value + } else { + # otherwise, make our data a list and add the new value + $attributeData[$attribute.key] = @( + $attributeData[$attribute.key] + ) + $attribute.Value + } + } + + # Now we want to try to match. + try { + # If either the `FilePattern` or `ContentTypePattern` matches + # we consider this to be a potential writer. + + # Loop thru the file patterns + foreach ($pattern in $attributeData.FilePattern) { + # skip parts that do not match. + if ($this.Uri -notmatch $pattern) { continue } + # Output the matching method + $method + continue nextMethod # and continue to the next method + } + # Loop thru the content type patterns + foreach ($pattern in $attributeData.ContentTypePattern) { + # skip parts that do not match. + if ($this.ContentType -notmatch $pattern) { continue } + # Output the matching method + $method + continue nextMethod # and continue to the next method. + } + } catch { + Write-Debug "$_" + } +}) + +# Now that we know all of the methods, we need to put them in order. +$WriteMethods = @( + $WriteMethods | # We can do this with a dynamic sort. + Sort-Object { + $in = $_ + # Simply walk over each attribute + foreach ($attr in $in.Script.Attributes) { + # any named `Order` with an integer value + if ($attr.Key -eq 'Order' -and $attr.Value -as [int]) { + # will use that value + return ($attr.Value -as [int]) + } + } + # Without an order attribute, use zero + return 0 + }, Name # and perform a secondary sort by name. +) + +# Now that we've gotten our writers and sorted them +# cache them on the object. +$this | Add-Member NoteProperty '#Writers' $WriteMethods -Force +# and return the cached value. +return $this.'#Writers' + + + + DefaultDisplay + Uri +ContentType +Reader + + + + + OpenPackage.Publisher + + + 11ty + Eleventy + + + atBlob + com.atproto.repo.uploadBlob + + + atRecord + com.atproto.repo.createRecord + + + atSession + com.atproto.server.createSession + + + GitTags + GitTag + + + at.markpub.markdown + + + + com.atproto.repo.createRecord + + + + com.atproto.repo.uploadBlob + + + + com.atproto.server.createSession + + + + Eleventy + + + + GitHubRelease + + + + GitTag + + + + Markdown + + + + org.poshweb.op + + + + PowerShellGallery + + + + Site + + + + README + # Open Pubishing with Open Packages + +Packages are just a bunch of files. + +On a technical level, publishing is just publishing some files someplace (often in the some way). + +There are many things we can publish, and many places we can publish them. + +`Publish-OpenPackage` is used to publish open packages. + +It accepts a publisher, any arguments, any options, and any input. + +If the publisher is a command or a script block, it will be called directly. + +If the publisher is the name of one of the methods in the type data of `OpenPackage.Publisher`, + +that method will be called. + +Any invalid named parameters will be removed prior to calling the publisher. + + +## OpenPackage.Publisher + +`OpenPackage.Publisher` is contains the built-in publishers. + +More will be added with time. + +### OpenPackage.Publisher.Site + +`OpenPackage.Publisher.Site` publishes packages as a static site. + +This will install a package to a `-DestinationPath` (default `./_site`). + +It will also generate an index.html for each markdown file in the package, if one does not already exist. + + + + + + + OpenPackage.Source + + + At + + + + AtBlob + + + + AtProtocol + + + + AtRecord + + + + AtType + + + + Dictionary + + + + Directory + + + + Node + + + + Nuget + + + + Python + + + + Repository + + + + Tar + + + + Url + + + + Zip + + + + README + # Open Package Sources + +Anything can become a package. + +However, different things become packages in different ways. + +`OpenPackage.Source` describes the supported open package sources. + +You should be able to use any `OpenPackage.Source` script to get packages, or data that can be put into a package. + +Current sources: + +## OpenPackage.Source + +OpenPackage.Source is the pseudo type that represents sources. + +Each ScriptMethod in the type can be used directly, and called from it's file, located in `./Types/OpenPackage.Source` + +`OpenPackage.Source` methods are most commonly called thru Get-OpenPackage (it is getting packages from a source) + +OpenPackage sources may call Get-OpenPackage recursively. + +If they do, they should include any parameters to `Get-OpenPackage` that are in all parameter sets. + +### At + +At enables `@` syntax, and supports getting content either from At Protocol or from any domain. + +For example: + +~~~PowerShell +op @( + '@MrPowerShell.com/site.standard.document' + '@MrPowerShell.com/site.standard.publication' + '@MrPowerShell.com/MrPowerShell.png' + '@MrPowerShell.com/MrPowerShell.svg' +) +~~~ + +This will: + +* Get [standard site documents](https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=mrpowershell.com&collection=site.standard.document&limit=100) from at protocol +* Get [standard site publications](https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=mrpowershell.com&collection=site.standard.publication&limit=100) from at protocol * Get [a png logo](https://MrPowerShell.com/MrPowerShell.png) from [MrPowerShell.com](https://MrPowerShell.com) +* Get [a svg logo](https://MrPowerShell.com/MrPowerShell.svg) from [MrPowerShell.com](https://MrPowerShell.com) + +If no domain is specified, at syntax will default to looking for a user or organization on GitHub.com. + +### AtBlob + +AtBlob is used to retreive public blobs from At Protocol. It does not store the blob in a package. + +### AtProtocol + +`AtProtocol` creates packages from At Protocol. It calls either AtBlob, AtRecord, or AtType and stores them in a package. + +### AtRecord + +`AtRecord` gets a single At Protocol record. It does not store the record in a package. + +### AtType + +`AtType` gets at records of a given type. It does not store the records in a package. + +### Dictionary + +`Dictionary` creates a package from a dictionary. + +Each key is a file or directory name. + +Each value can be file content or a dictionary. + +## Directory + +`Directory` creates a package from a directory + +Each file will be copied into the package. + +## Nuget + +`Nuget` gets a package from a Nuget repository. + +Since Nuget packages are already open packages, this directly returns the content. + +## Repository + +`Repository` gets a package from a git repository. + +This will attempt to clone this repository, and supports sparse filtering of the repository contents. + +Once the repository is cloned, it will be read in as a `Directory`. + +## Tar + +`tar` gets a package from a `.tar` or `.tar.gz` file. + +This requires either the tar application or the `System.Formats.Tar` assembly. + +## Url + +`url` gets a package from one or more urls. + +Any url is acceptable. + +Some urls may be processed by another source. + +For example: + +* Github urls will be processed with `Repository` +* `at://` urls will be processed with `AtProtocol` +* [Nuget](https://nuget.org/), [PowerShell Gallery](https://PowerShellGallery.com), and [Chocolatey](https://community.chocolatey.org/) packages will processed with `nuget` + +## Zip + +`zip` gets packages from a zip file. + +If the file is not already an open package, extracts the zip and creates an open package from the zip file. + + + + + OpenPackage.View + + + at.markpub.markdown + + + + FeatherIcon.svg + + + + Help.html + + + + Help.md + + + + Markdown.html + + + + org.poshweb.op + + + + site.standard.document + + + + Tree.html + + + + Tree.md + + + + Tree.txt + + + + + \ No newline at end of file diff --git a/OpenPackage.md b/OpenPackage.md new file mode 100644 index 0000000..804a78a --- /dev/null +++ b/OpenPackage.md @@ -0,0 +1,103 @@ +# Open Package + +Anything can become a package. + +## Open Packaging Conventions + +The [Open Package Conventions](https://en.wikipedia.org/wiki/Open_Packaging_Conventions) are an Open Protocol for Open Packages. + +They were first published in 2006, in [ECMA-376](https://ecma-international.org/publications-and-standards/standards/ecma-376/) + +Like many file formats, Open Packages are zip files in a trenchcoat. + +Any Open Packaging Convention file can be renamed to .zip and extracted as-is. + +Unlike many archives, Open Packages have some distinct advantages: + +1. They contain parts _and_ content types +2. They contain core metadata +3. They can contain relationships + +These advantages are huge! + +They allow Open Packages to be an [Open Platform](OpenPlatform.md) built on [Open Protocols](OpenProtocol.md). + +### Open Package Parts + +Any time we make a web request, we're effectively asking for a file. Open Packages call this a "part" + +Any time a server sends a response, it's providing a file's content. + +In order for a browser to treat this correctly, a server sets the Content-Type header. + +By containing both the file and the content type, Open Packages are a natural choice for describing simple servers. + +This makes Open Packages a natural choice for making an Open Platform for applications: + +Just create a part in a package and you've got yourself an app. + +## Package Metadata + +Open Packages also contain metadata. + +Each Open Package has a small set of "core" metadata that any package may possess: + +In .NET, this information is described in the[`.PackageProperties`](https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties?view=windowsdesktop-10.0&wt.mc_id=MVP_321542) of an Open Package. + +The core package properties are: + +* `Category` +* `ContentStatus` +* `ContentType` +* `Created` +* `Creator` +* `Description` +* `Identifier` +* `Keywords` +* `Language` +* `LastModifiedBy` +* `LastPrinted` +* `Modified` +* `Revision` +* `Subject` +* `Title` +* `Version` + +These properties can make it easier to discover and inspect packages. + +It also makes it pretty trivial to make a package store: + +All we really need is the identifier and the version (though the Description and Creator are also nice to have) + +Packages can also contain as much additional metadata as they want inside of files + +### File Metadata + +Any package can include additional metadata in the package in an easy-to-read file format. + +For some examples: + +* Any Nuget package will have a `.nuspec` which describes this data and more in an XML file. +* Any PowerShell module will have a `.psd1` which describes the module. +* Any JavaScript package will have a `package.json` which describes the package contents + +Most package creation tools are effectively just verifying some metadata exists and then creating a package. + +Having an [Open Platform](OpenPlatform.md) for Open Packages makes this much easier. + +## Package Relationships + +Open Packages can also have any number of relationships. + +A relationship has a few components: + +* TargetUri (what this relates to) +* TargetMode (`Internal` or `External`) +* RelationshipType (what kind of relationship) +* Identifier (the relationship id) + +Relationships can exist on the package and on any part in the package. + +Relationships allow package to describe how they relate to themselves and the rest of the world. + +This lets a package describe it's relationships with [Open Protocols](OpenProtocol.md) \ No newline at end of file diff --git a/OpenPlatform.md b/OpenPlatform.md new file mode 100644 index 0000000..bbd6fe9 --- /dev/null +++ b/OpenPlatform.md @@ -0,0 +1,43 @@ + +# Open Platform + +OP is an open platform. + +It is a wonderful tool for working with [Open Packages](OpenPackage.md). + +Anything can be a package. + +Each package is a self-contained file tree, and a server waiting to happen. + +This makes Open Packages an incredibly simple and open file format for applications. + +## Web Apps + +Open Packages are made of parts and content types. + +This makes them ready to run as servers. + +To make a "Hello World" web app with OP, we can just + +~~~PowerShell +Start-OP @{"index.html" = "

Hello World

"} +~~~ + +This will create a package containing index.html, and start a server. + +## Local Apps + +Almost any existing application can be run with this platform: + +Simply package it up and install away. + +For example, if we want to install the latest version of PowerShell, we can run: + +~~~PowerShell +# Installs PowerShell as a dotnet tool +Install-OpenPackage https://www.nuget.org/packages/PowerShell +# (note: this package does not include arm64 binaries) +~~~ + +On Unix / Linux machines, you will need to run `chmod +x` on any binaries or scripts for them to run. + diff --git a/OpenProtocol.md b/OpenProtocol.md new file mode 100644 index 0000000..7b1952c --- /dev/null +++ b/OpenProtocol.md @@ -0,0 +1,64 @@ +# Open Protocol + +Open Packages are an Open Protocol + +Anything can be a package. + +There is no strict requirement on what makes an open package. + +To store a package, it has to adhere to the [Open Package Conventions](https://en.wikipedia.org/wiki/Open_Packaging_Conventions). + +What the package contains is up to you! + +The protocol is open. + +That's the point. + +_Anything can be a package_. + +## Open Protocols + +Open Package is built upon Open Protocols. + +If a protocol can be read and written, we want to support it. + +We can get Open Packages from any number of Open Protocols: + +* Any URL +* Any At Protocol URI +* Any ZIP +* Any tar or tar.gz +* Any dictionary (recursively) + +Packages can written with any type of file, and many file formats are implicitly readable. + +For example, we can easily read any of these files as objects: + +* `.cs` +* `.clixml` +* `.json` +* `.ps1` +* `.psd1` +* `.svg` +* `.toml` +* `.xml` +* `.xsl` +* `.xsd` +* `.yaml` + +Packages can easily be exported to disk + +We would also welcome contributions to add additional file format support. + + + + + + + + + + + + + diff --git a/OptimalPerformance.md b/OptimalPerformance.md new file mode 100644 index 0000000..726abe3 --- /dev/null +++ b/OptimalPerformance.md @@ -0,0 +1,115 @@ +# Optimal Performance + +There are many advantages to archives. + +Open Packages have several performance characteristics worth understanding. + +## Reduced Disk Impact + +An Open Package is an archive that can be loaded into memory. + +When we use OP to Open Packages, we are reading the files into memory. + +Please note the plural. + +We have an entire filesystem in memory! + +This means that every operation on that filesystem is also in memory. + +And this means that the disk is involved much less often. + +This is especially helpful for home applications and physical servers. + +Disks take more time to access than memory. + +When dealing with lots of files, this tends to be especially painful. + +Each read of the disk could be anywhere, and seek times vary. + +By using packages, we eliminate our disk impact. Instead, we impact memory. + +## Reduced Memory Impact + +Our reduced disk impact makes our reduced memory impact even more priceless. + +Normally, if we were mapping files to memory, +we'd expect to see a memory impact roughly equal to the file size. + +You _might_ get clever, and use some light compression. +At this point you are simply making your own package, anyway. + +Because we are storing an _archive_ in memory, +we are getting compression for free. + +The data is all implicitly compressed and decompressed as we read and write content. + +This is quite nice! + +It also offers some interesting benefits. + +## Consistent References + +Once a package is loaded into memory, +every change to that package is to the exact same object. + +This means that packages are easy to pass around in memory. + +Multiple thread jobs can also access the same package. + +Therefore, changes to a loaded package are nearly instant. + +The change does not need to propagate, because _it is the same object_. + +This is also true for package parts. Once the object exists, we are not recreating it, we are simply sharing a reference to it. + +And that reference is consistent. + +This enables the core functionality of Open Package: + +## Extensible Instances + +We can easily extend objects in PowerShell. + +This happens in three ways: + +1. We can use `Add-Member` to add information to an instance +2. We can use `Update-TypeData` to add information about a type +3. We can load a `.types.ps1xml`, which contains type data. + +OP is built by extending the types .NET uses to load packages. + +Primarily: + +* `System.IO.Packaging.Package` +* `System.IO.Packaging.PackagePart` + +Each instance may also `Add-Member` to it's heart's content. + +If you want to make your own methods for working with a package, just `Add-Member` + +Speaking of adding things, let's talk layers + +## Flexible Layering + +We can use packages in layers. + +This is a lot like how containers work: +Each layer in a container is effectively a filesystem. + +The difference is that, in memory, we can add layers as we need, in any order. + +We can allow some packages to be writeable, and others to not be. + +We can serve one layer that provides an experience around any files it can fetch. + +This can allow us to build applications as layers, instead of making everything a monolith. + +We can also swap out layers. + +For an example, image a photo viewer. + +All it needs to do is render images, but there's lot of ways to do this. + +We can easily make a photo viewer as it's own package, +load it with our package containing photos, +and have ourselves a gallery! \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b046a21 --- /dev/null +++ b/README.md @@ -0,0 +1,517 @@ +# OP +[![OP](https://img.shields.io/powershellgallery/dt/OP)](https://www.powershellgallery.com/packages/OP/) +## An Overpowered little module for Open Packages +Anything can be a package. + +This command helps you make anything into a package. + +The following types of packages are currently supported: + +* Any [Open Packaging Convention](https://en.wikipedia.org/wiki/Open_Packaging_Conventions) files +* Any directory +* Any `*.zip` file +* Any `*.tar.gz` file +* Any nuget package +* Any git repository +* Any public at protocol URI +* Any dictionary (including nested dictionaries) +* Any url +* Any file + +_Anything can be a package_. + +Once we start to treat anything as a package, we can do amazing things with packages. + +Like: + +* Inspect any packages before we work with them. +* Modify the packages to customize their content. +* Split packages +* Filter our components. +* Join them back together. +* Search package content. +* Work with compressed trees of data. +* Have an in-memory containerized virtual filesystem. +* Serve a package from memory. +* Store data to N package layers. + +To put it simply, open packages are overpowered. + +## Installing and Importing + +You can install OP from the [PowerShell gallery](https://powershellgallery.com/) + +~~~PowerShell +Install-Module OP -Scope CurrentUser -Force +~~~ + +Once installed, you can import the module with: + +~~~PowerShell +Import-Module OP -PassThru +~~~ + + +You can also clone the repo and import the module locally: + +~~~PowerShell +git clone https://github.com/PoshWeb/OP +cd ./OP +Import-Module ./ -PassThru +~~~ + +## Functions +OP has 17 functions +### Copy-OpenPackage +#### Copies Open Packages +Copies Contents from one packages to another. +##### Examples +###### Example 1 +~~~PowerShell +Copy-OpenPackage -DestinationPath ./Examples/Copy.docx -InputObject ./Examples/Sample.docx -Force +~~~ +##### Parameters + +|Name|Type|Description| +|-|-|-| +|Destination|PSObject|The destination.
If this is not a `[IO.Packaging.Package]`, it will be considered a file path.| +|Include|String[]|Includes the specified parts.

Enter a wildcard pattern, such as `*.txt`

Wildcards are permitted.| +|Exclude|String[]|Excludes the specified parts.

Enter a wildcard pattern, such as `*.txt`

Wildcards are permitted.| +|IncludeContentType|String[]|Includes the specified content types.

Enter a wildcard pattern, such as `text/*`| +|ExcludeContentType|String[]|Excludes the specified content types.

Enter a wildcard pattern, such as `text/*`| +|InputObject|PSObject|The input object| +|Force|SwitchParameter|If set, will update existing packages.| + +### Export-OpenPackage +#### Exports OpenPackage packages +Exports loaded packages to a file or directory. +##### Parameters + +|Name|Type|Description| +|-|-|-| +|DestinationPath|String|The package file path.
If this has no extension, it will be considered a directory name.
If the path already exists and is a directory, it will be considered a directory name.| +|Include|String[]|Includes the specified parts.

Enter a wildcard pattern, such as `*.txt`| +|Exclude|String[]|Excludes the specified parts.

Enter a wildcard pattern, such as `*.txt`| +|IncludeContentType|String[]|Includes the specified content types.

Enter a wildcard pattern, such as `text/*`| +|InputObject|PSObject|The input object.
This must be a package loaded with this module.| +|Force|SwitchParameter|If set, will force the export even if a file already exists.| + +### Format-OpenPackage +#### Formats Open Package +Formats Open Packages using any view. +##### Parameters + +|Name|Type|Description| +|-|-|-| +|View|PSObject|The name of the view, or a view command or scriptblock| +|ArgumentList|PSObject[]|The Any Positional arguments for the view| +|InputObject|PSObject[]|Any input objects.| +|Option|IDictionary|Any options or parameters to pass to the View| + +### Get-OpenPackage +#### Gets Open Packages +Gets Open Packages from almost anything. + +Anything can be a package. + +This command helps you make anything into a package. + +The following types of packages are currently supported: + +* Any [Open Packaging Convention](https://en.wikipedia.org/wiki/Open_Packaging_Conventions) files +* Any directory +* Any `*.zip` file +* Any `*.tar.gz` file +* Any url +* Any public nuget package +* Any git repository +* Any public at protocol URI +* Any dictionary (including nested dictionaries) +* Any single file + +Anything can be a package. + +Once we start to treat anything as a package, we can do amazing things with packages. + +Like: + +* Inspect any packages before we work with them. +* Modify the packages to customize their content. +* Split packages +* Filter our components. +* Join them back together. +* Search package content. +* Work with compressed trees of data. +* Have an in-memory containerized virtual filesystem. +* Serve a package from memory. +* Store data to N package layers. +##### Examples +###### Example 1 +Make the current directory into a package +(do not try this at `$home`) +~~~PowerShell +Get-OpenPackage . +~~~ +###### Example 2 +Make the module into a package +~~~PowerShell +$opPackage = Get-Module OP | Get-OpenPackage +~~~ +###### Example 3 +~~~PowerShell +$opPackage = Get-Module OP | + Get-OpenPackage -Include *.ps1 +~~~ +###### Example 4 +Another way to make the current directory into a package +(do not try this at `$home`) +~~~PowerShell +Get-Item . | Get-OpenPackage +~~~ +###### Example 5 +Get a package from nuget +~~~PowerShell +$Avalonia = OP https://www.nuget.org/packages/Avalonia/ +~~~ +###### Example 6 +Get a package from Chocolatey +~~~PowerShell +$chocoPackage = op https://community.chocolatey.org/packages/chocolatey +~~~ +###### Example 7 +Get a package from the PowerShell gallery +~~~PowerShell +$turtlePackage = op https://powershellgallery.com/packages/Turtle +~~~ +###### Example 8 +Get a package from a single URL +~~~PowerShell +$imagePackage = op https://MrPowerShell.com/MrPowerShell.png +~~~ +###### Example 9 +Create a package from multiple URLs by piping back to ourself +~~~PowerShell +$svgAndPng = op https://MrPowerShell.com/MrPowerShell.png | + op https://MrPowerShell.com/MrPowerShell.svg +~~~ +###### Example 10 +Get a package from an at protocol URI +~~~PowerShell +$atPost = op at://mrpowershell.com/app.bsky.feed.post/3k4hf5dy6nf2g +~~~ +###### Example 11 +Get the most recent 50 posts +~~~PowerShell +$atLast50 = op at://mrpowershell.com/app.bsky.feed.post/ -First 50 +~~~ +###### Example 12 +Get all standard.site.documents for a user +~~~PowerShell +$standardSiteDocuments = op at://mrpowershell.com/site.standard.document/ +~~~ +#### Links +* [https://en.wikipedia.org/wiki/Open_Packaging_Conventions](https://en.wikipedia.org/wiki/Open_Packaging_Conventions) +##### Parameters + +|Name|Type|Description| +|-|-|-| +|ArgumentList|PSObject[]|Any unnamed arguments to the command.
Each argument will be treated as a potential -FilePath or -Uri.
Once the first related verb is detected, these will become arguments to that verb
(For example, `op . start` will get an open package and then start a server for that package)| +|FilePath|String|The path of a file to import| +|At|String[]|Gets Open Packages with `@` syntax.
Without a domain, `@` will be presumed to be a github
With a domain, `@` will look for an https url, and check at protocol| +|Uri|Uri[]|A URI to package.
If this URI is a git repository, will make a package out of the repository
If this URI is a nuget package url or powershell gallery url, will download the package.| +|Headers|IDictionary|Any additional headers to pass into a web request.| +|Repository|String|A Repository to package.
This can be the root of a repo or a link to a portion of the tree.
If a portion of the tree is provided, will perform a sparse clone of the repository| +|Branch|String|The github branch name.| +|SparseFilter|String[]|One or more optional sparse filters to a repository.
If these are provided, only files matching these filters will be downloaded.| +|AtUri|String[]|An At Uri to package.
This can be a single post or a collection of all posts of a type.| +|PDS|String|The personal data server. This is used in At Protocol requests.| +|Dictionary|IDictionary|Adds a dictionary of content to the package| +|BasePath|String|The base path within the package.
Content should be added beneath this base path.| +|NuGet|Uri|A Nuget Uri to package.
The package at this location will be downloaded and opened directly.| +|NodePackage|String[]|One or more Node Packages.| +|PythonPackage|String[]|One or more Python Packages.| +|Module|PSObject|A module to package
A loaded module name or moduleinfo object to package.
The loaded module must have a path property.
The files in this path will be packaged.| +|Include|String[]|A list of file wildcards to include.| +|Exclude|String[]|A list of file wildcards to exclude.| +|TypeMap|IDictionary|A content type map.
This maps extensions and URIs to a content type.| +|CompressionOption|CompressionOption|The compression option.| +|InputObject|PSObject|One or more input objects.| +|Installed|SwitchParameter|Gets the packages that are currently installed| +|Running|SwitchParameter|Gets packages that are currently running in a server| +|Force|SwitchParameter|If set, will force the redownload of various resources and remove existing files or directories| +|IncludeHidden|SwitchParameter|If set, will include hidden files and folders, except for files beneath `.git`| +|IncludeGit|SwitchParameter|If set, will include the `.git` directory contents if found.
By default, this content will be excluded.| +|IncludeNodeModule|SwitchParameter|If set, will include any content found in `/node_modules`.
By default, this content will be excluded.| +|IncludeSite|SwitchParameter|If set, will include any content found in `/_site`.
By default, this content will be excluded.| +|IncludeTotalCount|SwitchParameter|| +|Skip|UInt64|| +|First|UInt64|| + +### Install-OpenPackage +#### Installs an OpenPackage +Installs an OpenPackage into a destination on disk. +##### Parameters + +|Name|Type|Description| +|-|-|-| +|ArgumentList|PSObject[]|The arguments to Get-OpenPackage.| +|DestinationPath|String|The destination path.

If provided, this should be a directory, but can be a file.

If multiple packages will be installed and a -DestinationPath was provided,
all packages will be installed into that destination path.

If no destination path is provided,
only packages with an identifier will be installed.

Packages will install beneath the first `$env:OpenPackagePath`.

If the package has a version, it will install into a versioned subdirectory.| +|Include|String[]|Includes the specified parts.

Enter a wildcard pattern, such as `*.txt`| +|Exclude|String[]|Excludes the specified parts.

Enter a wildcard pattern, such as `*.txt`| +|IncludeContentType|String[]|Includes the specified content types.

Enter a wildcard pattern, such as `text/*`| +|ExcludeContentType|String[]|Excludes the specified content types.

Enter a wildcard pattern, such as `text/*`| +|InputObject|PSObject|The input object. If this is not a package, it will be passed thru.| +|Force|SwitchParameter|If set, will overwrite existing files.| +|Clear|SwitchParameter|If set, will clear the destination directory before installing.| +|PassThru|SwitchParameter|If set, will output the files that are expanded from the package.| +|WhatIf|SwitchParameter|| +|Confirm|SwitchParameter|| + +### Join-OpenPackage +#### Joins Open Packages +Joins multiple open packages into a single open package +##### Parameters + +|Name|Type|Description| +|-|-|-| +|InputObject|PSObject|| +|Include|String[]|Includes the specified parts.

Enter a wildcard pattern, such as `*.txt`

Wildcards are permitted.| +|Exclude|String[]|Excludes the specified parts.

Enter a wildcard pattern, such as `*.txt`

Wildcards are permitted.| +|IncludeContentType|String[]|Includes the specified content types.

Enter a wildcard pattern, such as `text/*`| +|ExcludeContentType|String[]|Excludes the specified content types.

Enter a wildcard pattern, such as `text/*`| +|Force|SwitchParameter|| + +### Lock-OpenPackage +#### Locks an Open Package +Locks an Open Package. + +Closes the package and copies it into a read-only package. +##### Examples +###### Example 1 +Import OP, make it a package, and lock it +~~~PowerShell +Import-Module OP -PassThru | + Get-OpenPackage | + Lock-OpenPackage +~~~ +###### Example 2 +Import OP, make it a package, and lock it +~~~PowerShell +impo OP -PassThru | op | lkop +~~~ +##### Parameters + +|Name|Type|Description| +|-|-|-| +|InputObject|PSObject|The input object. This should be a package.| + +### New-OpenPackage +#### Creates an empty open package +Creates an new empty open package +### Publish-OpenPackage +#### Publishes Open Package +Publishes Open Packages using any `OpenPackage.Publisher` or command. +##### Parameters + +|Name|Type|Description| +|-|-|-| +|Publisher|PSObject[]|The name of the Publisher, or a command or script block used to Publish.
One or more publishers may be provided. They will be processed in the order provided.| +|ArgumentList|PSObject[]|The Any Positional arguments for the view| +|InputObject|PSObject[]|Any input objects.| +|Option|IDictionary|Any options to pass to the View| +|WhatIf|SwitchParameter|| +|Confirm|SwitchParameter|| + +### Read-OpenPackage +#### Reads Open Package Bytes +Reads Bytes within an Open Package +##### Examples +###### Example 1 +~~~PowerShell +$package = OP @{"hello.txt" = "Hello world"} +$package | + Read-OpenPackage -Uri /hello.txt +~~~ +###### Example 2 +~~~PowerShell +$package = OP @{"hello.txt" = "Hello world"} +($package | + Read-OpenPackage -Uri /hello.txt -RangeStart 0 -RangeEnd 5) -as 'char[]' +~~~ +###### Example 3 +~~~PowerShell +$package = OP @{"hello.txt" = "Hello world"} +($package | + Read-OpenPackage -Uri /hello.txt -RangeStart 0 -RangeEnd 5) -as 'char[]' -join '' +~~~ +##### Parameters + +|Name|Type|Description| +|-|-|-| +|Uri|Uri[]|One or more part uris| +|RangeStart|Int64|A start range| +|RangeEnd|Int64|An ending range| +|InputObject|PSObject|The input object.
If this is not a package, it will be passed thru.| + +### Remove-OpenPackage +#### Removes parts from an open package +Removes content parts from an open package. +##### Examples +###### Example 1 +~~~PowerShell +Get-OpenPackage @{ + "a.html" = "

a html file

" +} | + Remove-OpenPackage -Uri '/a.html' +~~~ +###### Example 2 +~~~PowerShell +Get-OpenPackage @{ + "a.html" = "

a html file

" + "a.css" = "body { max-width: 100vw; height: 100vh}" +} | + Select-OpenPackage -Include *.html | + Remove-OpenPackage +~~~ +##### Parameters + +|Name|Type|Description| +|-|-|-| +|Uri|Uri[]|One or more URIs to remove| +|InputObject|PSObject|The input object.
If this is not a package, the input will be passed thru and nothing will be removed.| +|WhatIf|SwitchParameter|| +|Confirm|SwitchParameter|| + +### Select-OpenPackage +#### Selects Open Package content +Selects content from an Open Package using Regular Expressions or XPath +##### Parameters + +|Name|Type|Description| +|-|-|-| +|Pattern|PSObject[]|A list of patterns to match.| +|SimpleMatch|SwitchParameter|Indicates that the cmdlet uses a simple match rather than a regular expression match.| +|CaseSensitive|SwitchParameter|Indicates that the cmdlet matches are case-sensitive. By default, pattern matches aren't case-sensitive.| +|Quiet|SwitchParameter|Indicates that the cmdlet returns a simple response instead of a `[MatchInfo]` object.
The returned value is `$true` if the pattern is found or `$null` if the pattern is not found.| +|List|SwitchParameter|Only the first instance of matching text is returned from each input file.
This is the most efficient way to retrieve a list of files that have contents matching the regular expression.| +|NoEmphasis|SwitchParameter|By default, `Select-String` highlights the string that matches the pattern you searched for with the
`-Pattern` parameter. The `-NoEmphasis` parameter disables the highlighting.| +|Include|String[]|Includes the specified parts.

Enter a wildcard pattern, such as `*.txt`

Wildcards are permitted.| +|Exclude|String[]|Excludes the specified parts.

Enter a wildcard pattern, such as `*.txt`

Wildcards are permitted.| +|IncludeContentType|String[]|Includes the specified content types.

Enter a wildcard pattern, such as `text/*`| +|ExcludeContentType|String[]|Excludes the specified content types.

Enter a wildcard pattern, such as `text/*`| +|NotMatch|SwitchParameter|The `-NotMatch` parameter finds text that doesn't match the specified pattern.| +|AllMatches|SwitchParameter|Indicates that the cmdlet searches for more than one match in each line of text.
Without this parameter, `Select-String` finds only the first match in each line of text.

When `Select-String` finds more than one match in a line of text, it still emits only one
`[MatchInfo]` object for the line, but the `.Matches` property of the object contains all the
matches.| +|Context|Int32[]|Captures the specified number of lines before and after the line that matches the pattern.

If you enter one number as the value of this parameter, that number determines the number of lines
captured before and after the match. If you enter two numbers as the value, the first number
determines the number of lines before the match and the second number determines the number of lines
after the match. For example, `-Context 2,3`.| +|Raw|SwitchParameter|Causes the cmdlet to output only the matching strings, rather than **MatchInfo** objects. This is
the results in behavior that's the most similar to the Unix **grep** or Windows **findstr.exe**
commands.| +|XPath|PSObject|Specifies an XPath search query. The query language is case-sensitive.| +|Namespace|IDictionary|Specifies a hash table of the namespaces used in the XML.

Use the format`@{ = }`.

When the XML uses the default namespace, which begins with xmlns, use an arbitrary key for the
namespace name. You cannot use xmlns. In the XPath statement, prefix each node name with the
namespace name and a colon, such as `//namespaceName:Node`.| +|AstCondition|ScriptBlock[]|One or more Abstract Syntax Tree conditions.
These will select elements in any PowerShell scripts that match the condition.| +|InputObject|PSObject|The input object. This should be a package.| + +### Set-OpenPackage +#### Sets Open Package content +Sets content in an Open Packaging Conventions archive. +##### Examples +###### Example 1 +~~~PowerShell +$miniServer = Get-OpenPackage | + Set-OpenPackage -Uri '/index.html' -Content ([xml]"

Hello World

") -ContentType text/html | + Start-OpenPackage +Start-Process -FilePath $miniServer.Name +~~~ +##### Parameters + +|Name|Type|Description| +|-|-|-| +|Uri|Uri|The uri to set| +|Content|PSObject|The content to set.| +|ContentType|String|The content type. By default, `text/plain`| +|Depth|Int32|The serialization depth.| +|Option|IDictionary|The options used to write the content.| +|InputObject|PSObject|The input object.
This must be a package, and it must be writeable.| +|Force|SwitchParameter|Sets a part, even if it already exists.| + +### Split-OpenPackage +#### Splits Open Packages +Splits Open Packages into multiple parts +##### Examples +###### Example 1 +~~~PowerShell +Get-Module OP | + OP | + Split-OpenPackage +~~~ +##### Parameters + +|Name|Type|Description| +|-|-|-| +|Property|PSObject[]|One or more properties to group.| +|Include|String[]|Includes the specified parts.

Enter a wildcard pattern, such as `*.txt`

Wildcards are permitted.| +|Exclude|String[]|Excludes the specified parts.

Enter a wildcard pattern, such as `*.txt`

Wildcards are permitted.| +|IncludeContentType|String[]|Includes the specified content types.

Enter a wildcard pattern, such as `text/*`| +|ExcludeContentType|String[]|Excludes the specified content types.

Enter a wildcard pattern, such as `text/*`| +|InputObject|PSObject|| + +### Start-OpenPackage +#### Starts a OpenPackage Server +Starts a server, using one or more archive packages as the storage. +##### Parameters + +|Name|Type|Description| +|-|-|-| +|ArgumentList|PSObject[]|The path to an Open Package file, or a glob that matches multiple Open Package files.| +|RootUrl|String|The root url.
By default, this will be automatically to a random local port.
If running elevated, can be any valid http listener prefix, including `http://*/`| +|InputObject|PSObject[]|The input object. This can be provided to avoid loading a file from disk.| +|Allow|String[]|The allowed http verbs.| +|TypeMap|IDictionary|The content type map| +|Invokable|SwitchParameter|If set, the scripts in the package will be invokable.
This turns allows every PowerShell script in the package into server side code.
This should be used cautiously, and only with known packages.| +|ThrottleLimit|UInt16|The throttle limit.
This is the number of concurrent jobs that can be running at once.| +|BufferSize|UInt32|The buffer size.
If parts are smaller than this size, they will be streamed.
If parts are larger than this size, they will be handled in the background
(and may use a buffer of this size when accepting range requests)| +|Lifespan|TimeSpan|The lifespan of the server.
If provided, will automatically stop the server after it's life is over.| +|NodeCount|Byte|The number of nodes to run.
Each node can handle incoming requests.| + +### Uninstall-OpenPackage +#### Uninstalls OpenPackages +Uninstalls one or more OpenPackages. +##### Parameters + +|Name|Type|Description| +|-|-|-| +|Identifier|String[]|The package identifier.
If this is a fully qualified directory or file name, it will be removed.| +|Version|String[]|The package version| +|PackagePath|String[]|The direct path to any number of packages.
This will Remove-Item these paths| +|PackageRoot|String[]|The root location where packages are stored.
By default, this will be the locations specified in `$env:OpenPackagePath`| +|WhatIf|SwitchParameter|| +|Confirm|SwitchParameter|| + +### Write-OpenPackage +#### Writes bytes to an Open Package +Writes bytes directly to an Open Package part. The part must already exist. + +This can be used to rapidly update small segments of a package. + +It can also corrupt package contents, and should be used with care. + +To set package parts, use Set-OpenPackage +##### Examples +###### Example 1 +~~~PowerShell +$package = OP @{"hello.txt" = "Hello world"} +$package | + Write-OpenPackage -Uri /hello.txt -Buffer ($outputEncoding.GetBytes("y")) | + Get-OpenPackage ./hello.txt +~~~ +##### Parameters + +|Name|Type|Description| +|-|-|-| +|Uri|Uri|The Package Part Uri. This is the path to the content within a package.| +|Content|PSObject|The content to write.| +|Depth|Int32|The serialization depth.| +|Option|IDictionary|The options used to write the content.| +|RangeStart|Int64|The starting location for the write.| +|InputObject|PSObject|The package.| + +> 2025-2026 Start-Automating + +> [LICENSE](https://github.com/PoshWeb/OP/tree/main/LICENSE) diff --git a/README.md.ps1 b/README.md.ps1 new file mode 100644 index 0000000..2a709bd --- /dev/null +++ b/README.md.ps1 @@ -0,0 +1,229 @@ +<# +.SYNOPSIS + README.md.ps1 +.DESCRIPTION + README.md.ps1 makes README.md + + This is a simple and helpful scripting convention for writing READMEs. + + `./README.md.ps1 > ./README.md` + + Feel free to copy and paste this code. + + Please document your parameters, and add NOTES. +.NOTES + This README.md.ps1 is used to generate help for a module. + + It: + + * Outputs the name and description + * Provides installation instructions + * Lists commands + * Lists parameters + * Lists examples +.EXAMPLE + ./README.md.ps1 > ./README.md +.EXAMPLE + Get-Help ./README.md.ps1 +#> +param( +# The name of the module +[string]$ModuleName = $($PSScriptRoot | Split-Path -Leaf), + +# The domains that serve git repositories. +# If the project uri links to this domain, +# installation instructions will show how to import the module locally. +[string[]] +$GitDomains = @( + 'github.com', 'tangled.org', 'tangled.sh', 'codeberg.org' +), + +# If set, we don't need no badges. +[switch] +$NoBadge, + +# If set, will not display gallery instructions or badges +[switch] +$NotOnGallery +) + +Push-Location $PSScriptRoot + +# Import the module +$module = Import-Module "./$ModuleName.psd1" -PassThru + +# And output a header +"# $module" + +if (-not $NoBadge) { + # If it is on the gallery, show the downloads badge. + if (-not $NotOnGallery) { + @( + "[!" + "[$ModuleName](https://img.shields.io/powershellgallery/dt/$ModuleName)" + "](https://www.powershellgallery.com/packages/$ModuleName/)" + ) -join '' + } +} + +# Show the module description +"## $($module.Description)" + +# Show any intro section defined in the manifest +$module.PrivateData.PSData.PSIntro + +#region Boilerplate installation instructions +if (-not $NotOnGallery) { +@" + +## Installing and Importing + +You can install $ModuleName from the [PowerShell gallery](https://powershellgallery.com/) + +~~~PowerShell +Install-Module $($ModuleName) -Scope CurrentUser -Force +~~~ + +Once installed, you can import the module with: + +~~~PowerShell +Import-Module $ModuleName -PassThru +~~~ + +"@ +} +#endregion Gallery installation instructions + +#region Git installation instructions +$projectUri = $module.PrivateData.PSData.ProjectURI -as [uri] + +if ($projectUri.DnsSafeHost -in $GitDomains) { +@" + +You can also clone the repo and import the module locally: + +~~~PowerShell +git clone $projectUri +cd ./$ModuleName +Import-Module ./ -PassThru +~~~ + +"@ +} +#endregion Git installation instructions + +#region Exported Functions +$exportedFunctions = $module.ExportedFunctions +if ($exportedFunctions) { + + "## Functions" + + "$($ModuleName) has $($exportedFunctions.Count) function$( + if ($exportedFunctions.Count -gt 1) { "s"} + )" + + foreach ($export in $exportedFunctions.Keys | Sort-Object) { + # Get help if it there is help to get + $help = Get-Help $export + # If the help is a string, + if ($help -is [string]) { + # make it preformatted text + "~~~" + "$export" + "~~~" + } else { + # Otherwise, add list the export + "### $($export)" + + # And make it's synopsis a header + "#### $($help.SYNOPSIS)" + + # put the description below that + "$($help.Description.text -join [Environment]::NewLine)" + + if ($help.examples.example) { + # Show our examples + "##### Examples" + } + + + $exampleNumber = 0 + foreach ($example in $help.examples.example) { + $markdownLines = @() + $exampleNumber++ + $nonCommentLine = $false + "###### Example $exampleNumber" + + # Combine the code and remarks + $exampleLines = + @( + $example.Code + foreach ($remark in $example.Remarks.text) { + if (-not $remark) { continue } + $remark + } + ) -join ([Environment]::NewLine) -split '(?>\r\n|\n)' # and split into lines + + # Go thru each line in the example as part of a loop + $codeBlock = @(foreach ($exampleLine in $exampleLines) { + # Any comments until the first uncommentedLine are markdown + if ($exampleLine -match '^\#' -and -not $nonCommentLine) { + $markdownLines += $exampleLine -replace '^\#\s{0,1}' + } else { + $nonCommentLine = $true + $exampleLine + } + }) -join [Environment]::NewLine + + $markdownLines + "~~~PowerShell" + $CodeBlock + "~~~" + } + + $relatedUris = foreach ($link in $help.relatedLinks.navigationLink) { + if ($link.uri) { + $link.uri + } + } + if ($relatedUris) { + "#### Links" + foreach ($related in $relatedUris) { + "* [$related]($related)" + } + } + + # Make a table of parameters + if ($help.parameters.parameter) { + "##### Parameters" + + "" + + "|Name|Type|Description|" + "|-|-|-|" + foreach ($parameter in $help.Parameters.Parameter) { + "|$($parameter.Name)|$($parameter.type.name)|$( + $parameter.description.text -replace '(?>\r\n|\n)', '
' + )|" + } + + "" + } + + } + } +} +#endregion Exported Functions + +#region Copyright Notice +if ($module.Copyright) { + "> $($module.Copyright)" +} + +if ($module.PrivateData.PSData.LicenseUri) { + "" + "> [LICENSE]($($module.PrivateData.PSData.LicenseUri))" +} +#endregion Copyright Notice + +Pop-Location \ No newline at end of file diff --git a/Types/OpenPackage.ContentTypeMap/DefaultDisplay.txt b/Types/OpenPackage.ContentTypeMap/DefaultDisplay.txt new file mode 100644 index 0000000..06810ba --- /dev/null +++ b/Types/OpenPackage.ContentTypeMap/DefaultDisplay.txt @@ -0,0 +1 @@ +TypeMap \ No newline at end of file diff --git a/Types/OpenPackage.ContentTypeMap/DefaultTypeMap.psd1 b/Types/OpenPackage.ContentTypeMap/DefaultTypeMap.psd1 new file mode 100644 index 0000000..402b25e --- /dev/null +++ b/Types/OpenPackage.ContentTypeMap/DefaultTypeMap.psd1 @@ -0,0 +1,113 @@ +@{ + '.aac' = 'audio/aac' + '.abw' = 'application/x-abiword' + '.apng' = 'image/apng' + '.arc' = 'application/x-freearc' + '.avif' = 'image/avif' + '.avi' = 'video/x-msvideo' + '.azw' = 'application/vnd.amazon.ebook' + '.bin' = 'application/octet-stream' + '.bmp' = 'image/bmp' + '.bz' = 'application/x-bzip' + '.bz2' = 'application/x-bzip2' + '.c' = 'text/plain' + '.cda' = 'application/x-cdf' + '.cpp' = 'text/plain' + '.cs' = 'text/plain' + '.csh' = 'application/x-csh' + '.csproj' = 'application/xml' + '.css' = 'text/css' + '.csv' = 'text/csv' + '.doc' = 'application/msword' + '.docx' = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + '.eot' = 'application/vnd.ms-fontobject' + '.epub' = 'application/epub+zip' + '.exe' = 'application/executeable' + '.fsproj' = 'application/xml' + '.gz' = 'application/gzip' + '.go' = 'text/plain' + '.gif' = 'image/gif' + '.h' = 'text/plain' + '.htm' = 'text/html' + '.html' = 'text/html' + '.ico' = 'image/vnd.microsoft.icon' + '.ics' = 'text/calendar' + '.ini' = 'text/plain' + '.jar' = 'application/java-archive' + '.jpeg' = 'image/jpeg' + '.jpg' = 'image/jpeg' + '.js' = 'text/javascript' + '.jsm' = 'text/javascript' + '.json' = 'application/json' + '.jsonld' = 'application/ld+json' + '.jsx' = 'text/jsx' + '.md' = 'text/markdown' + '.mdx' = 'text/mdx' + '.mod' = 'text/plain' + '.mid' = 'audio/midi' + '.midi' = 'audio/midi' + '.mjs' = 'text/javascript' + '.mp3' = 'audio/mpeg' + '.mp4' = 'video/mp4' + '.mpeg' = 'video/mpeg' + '.mpkg' = 'application/vnd.apple.installer+xml' + '.nuspec' = 'application/xml' + '.op' = 'application/zip' + '.odp' = 'application/vnd.oasis.opendocument.presentation' + '.ods' = 'application/vnd.oasis.opendocument.spreadsheet' + '.odt' = 'application/vnd.oasis.opendocument.text' + '.oga' = 'audio/ogg' + '.ogv' = 'video/ogg' + '.ogx' = 'application/ogg' + '.opus' = 'audio/ogg' + '.otf' = 'font/otf' + '.png' = 'image/png' + '.pdf' = 'application/pdf' + '.php' = 'application/x-httpd-php' + '.ps1' = 'text/x-powershell' + '.ps1xml' = 'text/x-powershell+xml' + '.psm1' = 'text/x-powershell' + '.psd1' = 'text/x-powershell-data' + '.ppt' = 'application/vnd.ms-powerpoint' + '.pptx' = 'application/vnd.openxmlformats-officedocument.presentationml.presentation' + '.rar' = 'application/vnd.rar' + '.rtf' = 'application/rtf' + '.rs' = 'text/plain' + '.s' = 'text/plain' + '.sh' = 'application/x-sh' + '.sql' = 'application/sql' + '.srt' = 'application/x-subrip' + '.svg' = 'image/svg+xml' + '.tar' = 'application/x-tar' + '.tif' = 'image/tiff' + '.tiff' = 'image/tiff' + '.ts' = 'application/x-typescript' + '.ttf' = 'font/ttf' + '.ttl' = 'text/turtle' + '.txt' = 'text/plain' + '.url' = 'text/plain' + '.vb' = 'text/plain' + '.vbproj' = 'application/xml' + '.vbs' = 'text/plain' + '.vsd' = 'application/vnd.visio' + '.vtt' = 'text/vtt' + '.wav' = 'audio/wav' + '.weba' = 'audio/webm' + '.webm' = 'video/webm' + '.webp' = 'image/webp' + '.webmanifest' = 'application/manifest+json' + '.woff' = 'font/woff' + '.woff2' = 'font/woff2' + '.xaml' = 'application/xaml+xml' + '.xhtml' = 'application/xhtml+xml' + '.xls' = 'application/vnd.ms-excel' + '.xlsx' = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + '.xml' = 'application/xml' + '.xsd' = 'text/xsd' + '.xsl' = 'text/xsl' + '.xul' = 'application/vnd.mozilla.xul+xml' + '.zip' = 'application/zip' + '.3gp' = 'video/3gpp' + '.3g2' = 'video/3gpp2' + '.7z' = 'application/x-7z-compressed' +} \ No newline at end of file diff --git a/Types/OpenPackage.ContentTypeMap/README.md b/Types/OpenPackage.ContentTypeMap/README.md new file mode 100644 index 0000000..8e6bbfa --- /dev/null +++ b/Types/OpenPackage.ContentTypeMap/README.md @@ -0,0 +1,38 @@ +## OpenPackage.ContentTypeMap + +Packages can contain anything, but they do not always use consistent content types. + +In order to properly classify content, we need to map any file name into the right content type. + +While there is a wonderfully long list of content types at the +[IANA](https://www.iana.org/assignments/media-types/media-types.xhtml), it does not contain any file extensions. + +[MDN has a list of common types](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/MIME_types/Common_types). + +Sadly, this is far from complete. + +So, in order to make it easy to work with any type of content, OP contains this pseudo type, which gives us a customizable type map. + +## How This Works + +Various package commands will accept a typemap parameter, and default it to this object. + +Those commands will then use this typemap whenever they need to determine the content type of a file. + +For `Get-OpenPackage`, this will inform the content type used to pack the file. + +For `Start-OpenPackage`, this will inform the content type used to serve the part content. + +### OpenPackage.ContentTypeMap.DefaultTypeMap + +The Default Type Map is a PowerShell data file containing a mapping of known extensions to content types. + +### OpenPackage.ContentTypeMap.get_TypeMap + +When first requested, this creates a copy of the DefaultTypeMap. + +Subsequent requests will return this copy. + +### OpenPackage.ContentTypeMap.set_TypeMap + +When provided a dictionary mapping extension to content type, will change the content type map. diff --git a/Types/OpenPackage.ContentTypeMap/get_TypeMap.ps1 b/Types/OpenPackage.ContentTypeMap/get_TypeMap.ps1 new file mode 100644 index 0000000..ee44a81 --- /dev/null +++ b/Types/OpenPackage.ContentTypeMap/get_TypeMap.ps1 @@ -0,0 +1,18 @@ +<# +.SYNOPSIS + Gets the TypeMap +.DESCRIPTION + Gets the TypeMap. + + Will default to the values in `DefaultTypeMap` +#> +if ($this.'#TypeMap') { + return $this.'#TypeMap' +} +$typeMap = [Ordered]@{} +$defaultTypeMap = $this.DefaultTypeMap +foreach ($extension in ($defaultTypeMap.Keys | Sort-Object)) { + $typeMap[$extension] = $defaultTypeMap[$extension] +} +$this | Add-Member NoteProperty $typeMap '#TypeMap' -Force +return $typeMap \ No newline at end of file diff --git a/Types/OpenPackage.ContentTypeMap/set_TypeMap.ps1 b/Types/OpenPackage.ContentTypeMap/set_TypeMap.ps1 new file mode 100644 index 0000000..1ef969b --- /dev/null +++ b/Types/OpenPackage.ContentTypeMap/set_TypeMap.ps1 @@ -0,0 +1,24 @@ +<# +.SYNOPSIS + Sets the TypeMap +.DESCRIPTION + Sets the TypeMap. + + If an empty dictionary is provided, will clear the typemap. + + Any other values provided will override the existing content type map. +#> +param( +[Collections.IDictionary] +$TypeMap +) + +$thisTypeMap = $this.TypeMap + +if ($TypeMap.Count -eq 0) { + $thisTypeMap.Clear() +} else { + foreach ($key in $TypeMap.Keys) { + $thisTypeMap[$key] = $TypeMap[$key] + } +} \ No newline at end of file diff --git a/Types/OpenPackage.Part/Alias.psd1 b/Types/OpenPackage.Part/Alias.psd1 new file mode 100644 index 0000000..5104857 --- /dev/null +++ b/Types/OpenPackage.Part/Alias.psd1 @@ -0,0 +1,5 @@ +@{ + Get = "Read" + Set = "Write" + GitDates = 'GitDate' +} \ No newline at end of file diff --git a/Types/OpenPackage.Part/DefaultDisplay.txt b/Types/OpenPackage.Part/DefaultDisplay.txt new file mode 100644 index 0000000..a79dd72 --- /dev/null +++ b/Types/OpenPackage.Part/DefaultDisplay.txt @@ -0,0 +1,3 @@ +Uri +ContentType +Reader \ No newline at end of file diff --git a/Types/OpenPackage.Part/Export.ps1 b/Types/OpenPackage.Part/Export.ps1 new file mode 100644 index 0000000..c3f5d27 --- /dev/null +++ b/Types/OpenPackage.Part/Export.ps1 @@ -0,0 +1,63 @@ +<# +.SYNOPSIS + Exports package parts +.DESCRIPTION + Exports a part from a package. + + This will write the part to a file on disk. +#> +param( +# The export path +[Parameter(Mandatory)] +[string] +$Path +) + +if (-not $this.Uri -and -not $this.GetStream) { + return +} + +# The location may already exist +$outputFiles = if (Test-Path $path) { + # If it does, get the location + foreach ($foundItem in Get-Item $path) { + # If it is a file, we are writing directly to it. + if ($foundItem -is [IO.FileInfo]) { + $foundItem + } elseif ($foundItem -is [IO.DirectoryInfo]) { + # If it is a directory, put the output in that directory, + $outputPath = Join-Path $foundItem.FullName @( + # use the last segment as the file name. + $this.Uri -split '/' -ne '' + )[-1] + New-Item -ItemType File -Path $outputPath -Force + } + } +} else { + # if it does not exist, create a new file. + New-Item -ItemType File -Path $Path +} + +# If we don't have any output files at this point, something is wrong +# so return. +if (-not $outputFiles) { return } +# Go over each output file +foreach ($outputFile in $outputFiles) { + # Open it for writing + $openedFile = $outputFile.OpenWrite() + if (-not $openedFile) { continue } + # zero out the length + $openedFile.SetLength(0) + # Open our stream for read + $partStream = $this.GetStream('Open', 'Read') + # copy it to the file + $partStream.CopyTo($openedFile) + # and close up. + $partStream.Close() + $partStream.Dispose() + $openedFile.Close() + $openedFile.Dispose() +} + + + diff --git a/Types/OpenPackage.Part/GetHash.ps1 b/Types/OpenPackage.Part/GetHash.ps1 new file mode 100644 index 0000000..1113ea3 --- /dev/null +++ b/Types/OpenPackage.Part/GetHash.ps1 @@ -0,0 +1,24 @@ +<# +.SYNOPSIS + Gets the part hash +.DESCRIPTION + Gets the part hash using any supported algorithm (default SHA256) +.NOTES + Supports any algorithm from Get-FileHash +.LINK + Get-FileHash +#> +param([string]$Algorithm = 'SHA256') + +if (-not $this.GetStream) { + return +} + +$readStream = $this.GetStream('Open', 'Read') + +$fileHash = Get-FileHash -InputStream $readStream -Algorithm $Algorithm +$fileHash.Path = $this.Uri +$fileHash + +$readStream.Close() +$null = $readStream.DisposeAsync() \ No newline at end of file diff --git a/Types/OpenPackage.Part/Import.ps1 b/Types/OpenPackage.Part/Import.ps1 new file mode 100644 index 0000000..44ab0c8 --- /dev/null +++ b/Types/OpenPackage.Part/Import.ps1 @@ -0,0 +1,70 @@ +<# +.SYNOPSIS + Imports content into a part +.DESCRIPTION + Imports content into an Open Package part. +#> +param( +# The content to import. +# If this is a byte array, import bytes +# If this is a file, will import the contents of the file. +[PSObject] +$InputObject +) + +# If the input was bytes, or can be cast to bytes +if ($inputObject -is [byte[]] -or ( + $inputObject -is [object[]] -and + $InputObject -as [byte[]] +)) { + # get a buffer + $Buffer = [byte[]]$InputObject + # get our stream + $partStream = $this.GetStream() + # fix the size + $partStream.SetLength($Buffer.Length) + # and write to the stream. + $null = $partStream.Write($Buffer, 0, $Buffer.Length) + # Then clean up + $partStream.Close() + $partStream.Dispose() + return # and return. +} + +# If the input object existed as a path +if (Test-Path $inputObject) { + # set the inputobject to what we get + $InputObject = Get-Item $InputObject +} + +# If we are importing from a file +if ($inputObject -is [IO.FileInfo]) { + # try to open it for shared read + $openedFile = $InputObject.Open('Open', 'Read', 'Read') + # if we could not + if (-not $openedFile) { + # return + return + } + # Get our stream, + $partStream = $this.GetStream() + # zero it's length, + $partStream.SetLength(0) + # copy the file to our stream. + $openedFile.CopyTo($partStream) + # Then, close up + $partStream.Close() + $partStream.Dispose() + $openedFile.Close() + $openedFile.Dispose() + return # and return. +} + +# If we have a writer for this part +if ($this.Writer) { + # try to write this file's contents + $this.Write($InputObject) +} else { + # otherwise, write a warning. + Write-Warning "No writer found for $InputObject" +} \ No newline at end of file diff --git a/Types/OpenPackage.Part/PSTypeNames.txt b/Types/OpenPackage.Part/PSTypeNames.txt new file mode 100644 index 0000000..d2e2514 --- /dev/null +++ b/Types/OpenPackage.Part/PSTypeNames.txt @@ -0,0 +1 @@ +System.IO.Packaging.PackagePart \ No newline at end of file diff --git a/Types/OpenPackage.Part/Read.ps1 b/Types/OpenPackage.Part/Read.ps1 new file mode 100644 index 0000000..977588b --- /dev/null +++ b/Types/OpenPackage.Part/Read.ps1 @@ -0,0 +1,46 @@ +<# +.SYNOPSIS + Reads Open Package Part +.DESCRIPTION + Reads Open Package Parts, using the `.Reader` associated with this part. +#> +param( + # An optional input object + # If provided, content will be read from this object. + # If not provided, content will be read from this part. + [Alias('Input')] + [PSObject]$InputObject = $null, + + # Any options used to read the data. + [Alias('Options')] + [Collections.IDictionary]$Option = [Ordered]@{} +) + +# We want items returned from .Read to know their package and part +# so delcare a filter for that. +filter addPackageAndPart { + $in = $_ + # Make sure we do not overwrite any information + if ($in -and -not $in.package) { + $in | + Add-Member NoteProperty Package $this.Package -Force + } + if ($in -and -not $in.partUri) { + $in | + Add-Member NoteProperty PartUri $this.Uri -Force + } + $_ +} + +$orderedMethods = @($this.Reader) + +if (-not $orderedMethods) { + Write-Warning "No reader found for $($this.Uri)" + return +} +$method = $orderedMethods[0] +return $( + $method.Invoke($InputObject, $Option) | addPackageAndPart +) + + diff --git a/Types/OpenPackage.Part/ReadCSharp.ps1 b/Types/OpenPackage.Part/ReadCSharp.ps1 new file mode 100644 index 0000000..a9f5d7e --- /dev/null +++ b/Types/OpenPackage.Part/ReadCSharp.ps1 @@ -0,0 +1,36 @@ +<# +.SYNOPSIS + Reads Part Content as CSharp +.DESCRIPTION + Reads Package Part Content as CSharp code +#> +[Reflection.AssemblyMetadata( + # This should automatically apply to .cs files + 'FilePattern', + '\.cs$' +)] +param( + # An optional input object + # If provided, content will be read from this object. + # If not provided, content will be read from this part. + [Alias('Input')] + [PSObject]$InputObject = $null, + + # Any options used to read the data. + [Alias('Options')] + [Collections.IDictionary]$Option = [Ordered]@{} +) +if (-not $this.ReadText) { return } + +$partString = $this.ReadText($inputObject, $option) + +$null = Add-Type -AssemblyName Microsoft.CodeAnalysis.CSharp -PassThru + +if ('Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree' -as [Type]) { + $partString | + Add-Member NoteProperty SyntaxTree ( + [Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree]::ParseText($partString) + ) -Force -PassThru +} else { + $partString +} \ No newline at end of file diff --git a/Types/OpenPackage.Part/ReadCliXml.ps1 b/Types/OpenPackage.Part/ReadCliXml.ps1 new file mode 100644 index 0000000..d88d83a --- /dev/null +++ b/Types/OpenPackage.Part/ReadCliXml.ps1 @@ -0,0 +1,25 @@ +<# +.SYNOPSIS + Reads Part Content as Clixml +.DESCRIPTION + Reads Open Package Part Content as Clixml +#> +[Reflection.AssemblyMetadata('FilePattern', '\.(?>clixml|clix)?$')] +param( + # An optional input object + # If provided, content will be read from this object. + # If not provided, content will be read from this part. + [Alias('Input')] + [PSObject]$InputObject = $null, + + # Any options used to read the data. + [Alias('Options')] + [Collections.IDictionary]$Option = [Ordered]@{} +) +if (-not $this.ReadText) { return } + +$partText = $this.ReadText($InputObject, $option) + +return [Management.Automation.PSSerializer]::Deserialize($partText) + + diff --git a/Types/OpenPackage.Part/ReadFormData.ps1 b/Types/OpenPackage.Part/ReadFormData.ps1 new file mode 100644 index 0000000..38db2bc --- /dev/null +++ b/Types/OpenPackage.Part/ReadFormData.ps1 @@ -0,0 +1,37 @@ +<# +.SYNOPSIS + Reads Part Content as Form Data +.DESCRIPTION + Reads OpenPackage Part Content as Form Data +#> +[Reflection.AssemblyMetadata( + 'ContentTypePattern', + '[/\+]x-www-form-urlencoded' +)] +param( + # An optional input object + # If provided, content will be read from this object. + # If not provided, content will be read from this part. + [Alias('Input')] + [PSObject]$InputObject = $null, + + # Any options used to read the data. + [Alias('Options')] + [Collections.IDictionary]$Option = [Ordered]@{} +) +if (-not $this.ReadText) { return } + + +$partText = $this.ReadText($InputObject, $Option) + +$formData = [Web.HttpUtility]::ParseQueryString($partText) +$formDataObject = [Ordered]@{} +foreach ($key in $formData.Keys) { + if ($null -eq $formDataObject[$key]) { + $formDataObject[$key] = $formData[$key] + } else { + $formDataObject[$key] = @($formDataObject[$key]) + $formData[$key] + } +} +return $formDataObject + diff --git a/Types/OpenPackage.Part/ReadJson.ps1 b/Types/OpenPackage.Part/ReadJson.ps1 new file mode 100644 index 0000000..538aa4e --- /dev/null +++ b/Types/OpenPackage.Part/ReadJson.ps1 @@ -0,0 +1,35 @@ +<# +.SYNOPSIS + Reads Part Content as Json +.DESCRIPTION + Reads Open Package Part Content as Json +#> +[Reflection.AssemblyMetadata('FilePattern', '\.jsonc?$')] +[Reflection.AssemblyMetadata('ContentTypePattern', '[/\+]jsonc?$')] +param( + # An optional input object + # If provided, content will be read from this object. + # If not provided, content will be read from this part. + [Alias('Input')] + [PSObject]$InputObject = $null, + + # Any options used to read the data. + [Alias('Options')] + [Collections.IDictionary]$Option = [Ordered]@{} +) + +if (-not $this.ReadText) { return } + +$partText = $this.ReadText($InputObject, $Option) + +if (-not $partText) { return } + +# This is faster than ConvertFrom-Json +$ConvertFromJson = [Microsoft.PowerShell.Commands.JsonObject]::ConvertFromJson +if (-not $ConvertFromJson) { return } + +$ConvertFromJson.Invoke( + $partText, $option.hashtable, $option.Depth, [ref]$null +) + + diff --git a/Types/OpenPackage.Part/ReadJsonL.ps1 b/Types/OpenPackage.Part/ReadJsonL.ps1 new file mode 100644 index 0000000..cb294fd --- /dev/null +++ b/Types/OpenPackage.Part/ReadJsonL.ps1 @@ -0,0 +1,37 @@ +<# +.SYNOPSIS + Reads Part Content as Json Lines +.DESCRIPTION + Reads Open Package Part Content as Json Lines +.NOTES + Also should work for asciienma `.cast` files and `.jsonnd`, + which are also json files delimited by newlines. +#> +[Reflection.AssemblyMetadata( + 'FilePattern', + '\.(?>cast|jsonl|jsonnd)?$' +)] +param( + # An optional input object + # If provided, content will be read from this object. + # If not provided, content will be read from this part. + [Alias('Input')] + [PSObject]$InputObject = $null, + + # Any options used to read the data. + [Alias('Options')] + [Collections.IDictionary]$Option = [Ordered]@{} +) +if (-not $this.ReadText) { return } +$partText = $this.ReadText($InputObject, $Option) +# This is faster than the cmdlet ConvertFrom-Json +$ConvertFromJson = [Microsoft.PowerShell.Commands.JsonObject]::ConvertFromJson +if (-not $ConvertFromJson) { return } + +foreach ($line in $partText -split '(?>\r\n|\n)') { + $ConvertFromJson.Invoke( + $line, $option.hashtable, $option.Depth, [ref]$null + ) +} + + diff --git a/Types/OpenPackage.Part/ReadMarkdown.ps1 b/Types/OpenPackage.Part/ReadMarkdown.ps1 new file mode 100644 index 0000000..91d4a27 --- /dev/null +++ b/Types/OpenPackage.Part/ReadMarkdown.ps1 @@ -0,0 +1,89 @@ +<# +.SYNOPSIS + Reads Part Content as Markdown +.DESCRIPTION + Reads Package Part Content as Markdown +#> +[Reflection.AssemblyMetadata( + # This should automatically apply to .md or .markdown files + 'FilePattern', + '\.(?>md|markdown)$' +)] +[Reflection.AssemblyMetadata( + # This should automatically apply to markdown content types + 'ContentTypePattern', + '[/\+]markdown' +)] +param( + # An optional input object + # If provided, content will be read from this object. + # If not provided, content will be read from this part. + [Alias('Input')] + [PSObject]$InputObject = $null, + + # Any options used to read the data. + [Alias('Options')] + [Collections.IDictionary]$Option = [Ordered]@{} +) + +if (-not $inputObject) { + if (-not $this.ReadText) { return } + + $partString = $this.ReadText($InputObject, $Option) +} else { + $partString = "$inputObject" +} + + +if (-not ('Markdig.MarkdownPipelineBuilder' -as [type])) { + Write-Warning "Markdig not loaded (ConvertFrom-Markdown is not installed)" + $partString = [PSObject]::new($partString) + $partString.pstypenames.add('text/markdown') + $partString + return +} + +$mdPipelineBuilder = [Markdig.MarkdownPipelineBuilder]::new() +$mdPipeline = [Markdig.MarkdownExtensions]::UseYamlFrontMatter( + [Markdig.MarkdownExtensions]::UsePipeTables($mdPipelineBuilder) +).Build() + +try { + if ($partString -match '^---') { + $null, $yamlheader, $md = $partString -split '---', 3 + + $convertFromYamlCommand = + $ExecutionContext.SessionState.InvokeCommand.GetCommand('ConvertFrom-Yaml', 'Cmdlet,Function') + if (-not $convertFromYamlCommand -or -not + $convertFromYamlCommand.Parameters.InputObject + ) { + Write-Warning "Convert-FromYaml not found, please install YaYaml to read Yaml Header" + [PSCustomObject]@{ + PSTypeName = 'text/markdown' + Html = [Markdig.Markdown]::ToHtml($partString, $mdPipeline) + Markdown = "$partString" + } | + Add-Member ScriptMethod ToString { "$($this.Html)" } -Force -PassThru + } else { + $yamlObject = & $convertFromYamlCommand -InputObject $yamlheader + if ($yamlObject) { + [PSCustomObject]@{ + PSTypeName='text/markdown' + Html = [Markdig.Markdown]::ToHtml($partString, $mdPipeline) + Markdown = "$md" + FrontMatter = $yamlObject + } | + Add-Member ScriptMethod ToString { "$($this.Html)" } -Force -PassThru + } + } + } else { + [PSCustomObject]@{ + PSTypeName = 'text/markdown' + Html = [Markdig.Markdown]::ToHtml($partString, $mdPipeline) + Markdown = "$partString" + } | + Add-Member ScriptMethod ToString { "$($this.Html)" } -Force -PassThru + } +} catch { + Write-Warning "'$($this.Uri)' was not valid markdown: $_" +} diff --git a/Types/OpenPackage.Part/ReadPowerShell.ps1 b/Types/OpenPackage.Part/ReadPowerShell.ps1 new file mode 100644 index 0000000..a63a4b6 --- /dev/null +++ b/Types/OpenPackage.Part/ReadPowerShell.ps1 @@ -0,0 +1,27 @@ +<# +.SYNOPSIS + Reads Part Content as PowerShell +.DESCRIPTION + Reads Open Package Part Content as PowerShell +#> +[Reflection.AssemblyMetadata( + # This should automatically apply to `.ps1` and `.psm1` files + 'FilePattern', + '\.psm?1$' +)] +param( + # An optional input object + # If provided, content will be read from this object. + # If not provided, content will be read from this part. + [Alias('Input')] + [PSObject]$InputObject = $null, + + # Any options used to read the data. + [Alias('Options')] + [Collections.IDictionary]$Option = [Ordered]@{} +) + +if (-not $this.ReadText) { return } +[ScriptBlock]::Create($this.ReadText($InputObject, $Option)) + + diff --git a/Types/OpenPackage.Part/ReadPowerShellData.ps1 b/Types/OpenPackage.Part/ReadPowerShellData.ps1 new file mode 100644 index 0000000..bcf1917 --- /dev/null +++ b/Types/OpenPackage.Part/ReadPowerShellData.ps1 @@ -0,0 +1,59 @@ +<# +.SYNOPSIS + Reads Part Content as PowerShell Data +.DESCRIPTION + Reads Package Part Content as a PowerShell Data block +#> +[Reflection.AssemblyMetadata( + # This should automatically apply to .txt files + 'FilePattern', + '\.psd1$' +)] +param( + # An optional input object + # If provided, content will be read from this object. + # If not provided, content will be read from this part. + [Alias('Input')] + [PSObject]$InputObject = $null, + + # Any options used to read the data. + [Alias('Options')] + [Collections.IDictionary]$Option = [Ordered]@{} +) + +if (-not $this.ReadText) { return } + +$supportedCommands = @( + if ($option.SupportedCommand) { + $option.SupportedCommand + } + if ($option.SupportedCommands) { + $option.SupportedCommands + } +) + +$datablock = [ScriptBlock]::Create("data $( + if ($supportedCommands) { + '-supportedCommand' + "'$($supportedCommands -replace "'","''" -join "','")'" + } +) {$( + $this.ReadText($InputObject, $Option) +)}") +if ($datablock.Ast.EndBlock.Statements.Count -gt 1) { return } +if ($datablock.Ast.EndBlock.Statements[0] -isnot + [Management.Automation.Language.DataStatementAst] +) { + return +} +if ($datablock.Ast.EndBlock.Statements[0].CommandsAllowed) { + foreach ($commandMaybeAllowed in $datablock.Ast.EndBlock.Statements[0].CommandsAllowed) { + if (-not $commandMaybeAllowed) { continue } + if ($supportedCommands -notcontains "$commandMaybeAllowed") { + Write-Warning "Unsupported command $commandMaybeAllowed" + return + } + } +} +& $datablock + diff --git a/Types/OpenPackage.Part/ReadText.ps1 b/Types/OpenPackage.Part/ReadText.ps1 new file mode 100644 index 0000000..a9f79bb --- /dev/null +++ b/Types/OpenPackage.Part/ReadText.ps1 @@ -0,0 +1,59 @@ +<# +.SYNOPSIS + Reads Part Content as Text +.DESCRIPTION + Reads Package Part Content as Text +#> +[Reflection.AssemblyMetadata( + # This should automatically apply to .txt files, sql files, modelfiles, and dockerfiles. + 'FilePattern', + '(?>Dockerfile|Modelfile|c|h|cpp|cs|js|md|sql|txt)$' +)] +[Reflection.AssemblyMetadata( + # This should automatically apply to any text/ content types + 'ContentTypePattern', + '^text/' +)] +[Reflection.AssemblyMetadata( + # This has a higher order, indicating it should be run later than most. + 'Order', + 100 +)] +param( + # An optional input object + # If provided, content will be read from this object. + # If not provided, content will be read from this part. + [Alias('Input')] + [PSObject]$InputObject = $null, + + # Any options used to read the data. + [Alias('Options')] + [Collections.IDictionary]$Option = [Ordered]@{} +) + +if (-not $InputObject -and + ($this -is [IO.Packaging.PackagePart]) +) { + $Stream = $this.GetStream('Open','Read') + + $streamReader = [IO.StreamReader]::new($Stream, $true) + + $partText = $streamReader.ReadToEnd() + + $Stream.Close() + $Stream.Dispose() + + $streamReader.Close() + $streamReader.Dispose() + + $partText +} elseif ($InputObject -is [IO.Stream]) { + + $streamReader = [IO.StreamReader]::new($InputObject, $true) + $partText = $streamReader.ReadToEnd() + $streamReader.Close() + $streamReader.Dispose() + $partText +} elseif ($inputObject -is [string]) { + $InputObject +} diff --git a/Types/OpenPackage.Part/ReadToml.ps1 b/Types/OpenPackage.Part/ReadToml.ps1 new file mode 100644 index 0000000..267e3d7 --- /dev/null +++ b/Types/OpenPackage.Part/ReadToml.ps1 @@ -0,0 +1,43 @@ +<# +.SYNOPSIS + Reads Part Content as Toml +.DESCRIPTION + Reads Package Part Content as Toml +.LINK + https://toml.io/ +.LINK + https://github.com/jborean93/PSToml +#> +[Reflection.AssemblyMetadata( + # This should automatically apply to .yaml files + 'FilePattern', + '\.toml$' +)] +param( + # An optional input object + # If provided, content will be read from this object. + # If not provided, content will be read from this part. + [Alias('Input')] + [PSObject]$InputObject = $null, + + # Any options used to read the data. + [Alias('Options')] + [Collections.IDictionary]$Option = [Ordered]@{} +) + + +if (-not $this.ReadText) { return } + +$convertFromTomlCommand = $ExecutionContext.SessionState.InvokeCommand.GetCommand('ConvertFrom-Toml', 'Cmdlet,Function') +$partString = $this.ReadText($InputObject, $Option) + +if (-not $convertFromTomlCommand -or -not $convertFromTomlCommand.Parameters.InputObject) { + Write-Warning "ConvertFrom-Toml not found, please install PSToml" + $partString +} else { + try { + $partString | & $convertFromTomlCommand -ErrorAction Stop + } catch { + Write-Warning "'$($thisPart.Uri)' was not valid toml: $_" + } +} \ No newline at end of file diff --git a/Types/OpenPackage.Part/ReadXml.ps1 b/Types/OpenPackage.Part/ReadXml.ps1 new file mode 100644 index 0000000..3d0368d --- /dev/null +++ b/Types/OpenPackage.Part/ReadXml.ps1 @@ -0,0 +1,30 @@ +<# +.SYNOPSIS + Reads Part Content XML +.DESCRIPTION + Reads an OpenPackage Part's Content as XML +#> +[Reflection.AssemblyMetadata( + 'FilePattern', '\.(?>svg|ps1xml|xml|xhtml|.+proj|nuspec)$' +)] +[Reflection.AssemblyMetadata( + 'ContentTypePattern', '[/\+]xml?$' +)] +param( + # An optional input object + # If provided, content will be read from this object. + # If not provided, content will be read from this part. + [Alias('Input')] + [PSObject]$InputObject = $null, + + # Any options used to read the data. + [Alias('Options')] + [Collections.IDictionary]$Option = [Ordered]@{} +) + +if (-not $this.ReadText) { return } +$partText = $this.ReadText($InputObject, $Option) + +return [xml]$partText + + diff --git a/Types/OpenPackage.Part/ReadXsd.ps1 b/Types/OpenPackage.Part/ReadXsd.ps1 new file mode 100644 index 0000000..891aaaf --- /dev/null +++ b/Types/OpenPackage.Part/ReadXsd.ps1 @@ -0,0 +1,64 @@ +<# +.SYNOPSIS + Reads Part Content as Xml Schema Definitions +.DESCRIPTION + Reads an OpenPackage Part's Content as Xml Schema Definitions +#> +[Reflection.AssemblyMetadata( + 'FilePattern', '\.xsd?$' +)] +[Reflection.AssemblyMetadata( + 'ContentTypePattern', '[/\+]xsd?$' +)] + +param( + # An optional input object + # If provided, content will be read from this object. + # If not provided, content will be read from this part. + [Alias('Input')] + [PSObject]$InputObject = $null, + + # Any options used to read the data. + [Alias('Options')] + [Collections.IDictionary]$Option = [Ordered]@{} +) + +$xmlReaderSettings = [Xml.XmlReaderSettings]::new() +$xmlReaderSettings.DtdProcessing = 'Parse' +foreach ($key in $options.Keys) { + if ($xmlReaderSettings.psobject.Properties[$key].IsSettable) { + $xmlReaderSettings.$key = $option[$key] + } +} + +if ($InputObject -is [IO.Stream]) { + try { + $xmlReader = [Xml.XmlReader]::Create($InputObject, $xmlReaderSettings) + [Xml.Schema.XmlSchema]::Read($xmlReader,{}) + } catch { + $_ + } finally { + if ($xmlReader) { + $xmlReader.Close() + $xmlReader.Dispose() + } + } +} elseif ($this.GetStream) { + try { + $partStream = $this.GetStream('Open','Read') + $xmlReader = [Xml.XmlReader]::Create($partStream, $xmlReaderSettings) + [Xml.Schema.XmlSchema]::Read($xmlReader,{}) + } catch { + $_ + } finally { + if ($xmlReader) { + $xmlReader.Close() + $xmlReader.Dispose() + } + + $partStream.Close() + $partStream.Dispose() + } +} + + diff --git a/Types/OpenPackage.Part/ReadXslt.ps1 b/Types/OpenPackage.Part/ReadXslt.ps1 new file mode 100644 index 0000000..70c8a67 --- /dev/null +++ b/Types/OpenPackage.Part/ReadXslt.ps1 @@ -0,0 +1,58 @@ +<# +.SYNOPSIS + Reads Part Content XSLT +.DESCRIPTION + Reads an OpenPackage Part's Content as XSLT, or eXtensible Stylesheet Language Transforms +#> +[Reflection.AssemblyMetadata( + 'FilePattern', '\.xslt?$' +)] +[Reflection.AssemblyMetadata( + 'ContentTypePattern', '[/\+]xslt?$' +)] + +param( + # An optional input object + # If provided, content will be read from this object. + # If not provided, content will be read from this part. + [Alias('Input')] + [PSObject]$InputObject = $null, + + # Any options used to read the data. + [Alias('Options')] + [Collections.IDictionary]$Option = [Ordered]@{} +) + +if ($InputObject -is [IO.Stream]) { + try { + $xmlReader = [Xml.XmlReader]::Create($InputObject) + $xslTransformer = [xml.Xsl.XslCompiledTransform]::new() + $xslTransformer.Load($xmlReader) + $xslTransformer + } catch { + $_ + } finally { + if ($xmlReader) { + $xmlReader.Close() + $xmlReader.Dispose() + } + } +} elseif ($this.GetStream) { + try { + $partStream = $this.GetStream('Open', 'Read') + $xmlReader = [Xml.XmlReader]::Create($partStream) + $xslTransformer = [xml.Xsl.XslCompiledTransform]::new() + $xslTransformer.Load($xmlReader) + $xslTransformer + } catch { + $_ + } finally { + if ($xmlReader) { + $xmlReader.Close() + $xmlReader.Dispose() + } + + $partStream.Close() + $partStream.Dispose() + } +} \ No newline at end of file diff --git a/Types/OpenPackage.Part/ReadYaml.ps1 b/Types/OpenPackage.Part/ReadYaml.ps1 new file mode 100644 index 0000000..60abe10 --- /dev/null +++ b/Types/OpenPackage.Part/ReadYaml.ps1 @@ -0,0 +1,41 @@ +<# +.SYNOPSIS + Reads Part Content as Yaml +.DESCRIPTION + Reads Package Part Content as Yaml +.LINK + https://yaml.org/ +.LINK + https://github.com/jborean93/PowerShell-Yayaml +#> +[Reflection.AssemblyMetadata( + # This should automatically apply to .yaml files + 'FilePattern', + '\.ya?ml$' +)] +param( + # An optional input object + # If provided, content will be read from this object. + # If not provided, content will be read from this part. + [Alias('Input')] + [PSObject]$InputObject = $null, + + # Any options used to read the data. + [Alias('Options')] + [Collections.IDictionary]$Option = [Ordered]@{} +) + + +if (-not $this.ReadText) { return } +$convertFromYamlCommand = $ExecutionContext.SessionState.InvokeCommand.GetCommand('ConvertFrom-Yaml', 'Cmdlet,Function') +$partString = $this.ReadText($InputObject, $Option) +if (-not $convertFromYamlCommand -or -not $convertFromYamlCommand.Parameters.InputObject) { + Write-Warning "Convert-FromYaml not found, please install YaYaml" + $partString +} else { + try { + $partString | & $convertFromYamlCommand -ErrorAction Stop + } catch { + Write-Warning "'$($thisPart.Uri)' was not valid yaml: $_" + } +} \ No newline at end of file diff --git a/Types/OpenPackage.Part/Relate.ps1 b/Types/OpenPackage.Part/Relate.ps1 new file mode 100644 index 0000000..9e9fbbb --- /dev/null +++ b/Types/OpenPackage.Part/Relate.ps1 @@ -0,0 +1,56 @@ +<# +.SYNOPSIS + Adds Relationships to a package part +.DESCRIPTION + Simplifies adding relationships to a package part. + + All relationships are external. + + If a relationship type is not provided, will default to "unknown" + + If no id is provided, will default to the relationship type. + + If relations with that type already exists, will append a counter to the id. +#> +param( +# The relationship uri +[Parameter(Mandatory)] +[uri]$uri, +# The relationship type. +# If this is not provided, will default to `unknown` +[string]$type, +# The relationship id +# If not provided, will default to the type. +# If any relationships of the type already exist, will append a counter to the id. +[string]$id +) + +# Return if we cannot have relationships. +if (-not $this.GetRelationships) { return } +if (-not $this.CreateRelationship) { return } +# Default the type to `unknown` +if (-not $type) { $type = 'unknown'} +# and default the `$id` to the `$type`. +if (-not $id) { $id = $type } + +# Get our relations +$relations = @( + foreach ($relation in $this.GetRelationships()) { + if ($relation.RelationshipType -eq $type) { + $relation + } + } +) + +# If we have no relations, +if (-not $relations) { + # hard relate. + $this.CreateRelationship( + $uri, 'External', $type, $id + ) +} else { + # Otherwise, create another relationship of the same type. + $this.CreateRelationship( + $uri, 'External', $type, "$($id)$($relations.Count)" + ) +} \ No newline at end of file diff --git a/Types/OpenPackage.Part/Write.ps1 b/Types/OpenPackage.Part/Write.ps1 new file mode 100644 index 0000000..a25dbde --- /dev/null +++ b/Types/OpenPackage.Part/Write.ps1 @@ -0,0 +1,41 @@ +<# +.SYNOPSIS + Writes Open Package Parts +.DESCRIPTION + Writes Open Package Parts, using the `.Writer` associated with this part. +#> +param( +# The object to write. +[Alias('Input','Content','Text')] +[PSObject] +$InputObject, + +<# + +Any options used to write the object + +Commonly Supported Options: + +|Option|Description| +|-|-| +|Depth|The serialization depth| +|Encoding|The text encoding| +|Stream|Optional destination stream| + +#> +[Alias('Options')] +[Collections.IDictionary] +$Option = [Ordered]@{} +) + +$orderedMethods = @($this.Writer) + +if (-not $orderedMethods) { + Write-Warning "No writer found for $($this.Uri)" + return +} + +$method = $orderedMethods[0] +if ($method) { + return $method.Invoke($InputObject, $option) +} diff --git a/Types/OpenPackage.Part/WriteClixml.ps1 b/Types/OpenPackage.Part/WriteClixml.ps1 new file mode 100644 index 0000000..9605a8d --- /dev/null +++ b/Types/OpenPackage.Part/WriteClixml.ps1 @@ -0,0 +1,38 @@ +<# +.SYNOPSIS + Write Part Content as Clixml +.DESCRIPTION + Write Open Package Part Content as Clixml +#> +[Reflection.AssemblyMetadata('FilePattern', '\.(?>clixml|clix)?$')] +param( +# The object to write. +[Alias('Input','Content','Text')] +[PSObject] +$InputObject, + +<# + +Any options used to write the object + +Supported Options: + +|Option|Description| +|-|-| +|Depth|The serialization depth| +|Encoding|The text encoding| +|Stream|Optional destination stream| + +#> +[Collections.IDictionary] +$Option = [Ordered]@{} +) +if (-not $this.WriteText) { return } + +if (-not $option.Depth) { + $option.Depth = $FormatEnumerationLimit * 2 +} + +$this.WriteText([Management.Automation.PSSerializer]::Serialize($InputObject, $option.Depth), $option) + + diff --git a/Types/OpenPackage.Part/WriteJson.ps1 b/Types/OpenPackage.Part/WriteJson.ps1 new file mode 100644 index 0000000..eee94ed --- /dev/null +++ b/Types/OpenPackage.Part/WriteJson.ps1 @@ -0,0 +1,55 @@ +<# +.SYNOPSIS + Writes Part Content as Json +.DESCRIPTION + Writes Open Package Part Content as Json +#> +[Reflection.AssemblyMetadata('FilePattern', '\.jsonc?$')] +[Reflection.AssemblyMetadata('ContentTypePattern', '[/\+]jsonc?$')] +param( +# The object to write. +[Alias('Input','Content','Text')] +[PSObject] +$InputObject, + +<# + +Any options used to write the object + +Supported Options: + +|Option|Description| +|-|-| +|Depth|The serialization depth| +|Encoding|The text encoding| +|Stream|Optional destination stream| + +#> +[Collections.IDictionary] +$Option = [Ordered]@{} +) + +# If this object does not have a write text method, return. +if (-not $this.WriteText) { throw 'No `.WriteText()`'; return } + +# If no depth was set, +if (-not $option.Depth) { + # use double the format enumeration limit (by default, 8) + $option.Depth = $FormatEnumerationLimit * 2 +} + +# If we have a .Package and .PartUri property +if ($InputObject.Package -is [IO.Packaging.Package] -and + $InputObject.PartUri -is [uri]) { + # avoid putting them in the object + $text = ConvertTo-Json -InputObject ( + $InputObject | + Select-Object -Property * -ExcludeProperty 'Package', 'PartUri' + ) -Depth $Option.Depth +} else { + # Convert any other objects to json. + $text = ConvertTo-Json -InputObject $InputObject -Depth $Option.Depth +} + +# Then, write the text +$this.WriteText($text, $Option) \ No newline at end of file diff --git a/Types/OpenPackage.Part/WriteJsonL.ps1 b/Types/OpenPackage.Part/WriteJsonL.ps1 new file mode 100644 index 0000000..1c11781 --- /dev/null +++ b/Types/OpenPackage.Part/WriteJsonL.ps1 @@ -0,0 +1,60 @@ +<# +.SYNOPSIS + Writes Part Content as Json Lines +.DESCRIPTION + Writes Open Package Part Content as Json Lines +#> +[Reflection.AssemblyMetadata( + 'FilePattern', + '\.(?>cast|jsonl|jsonnd)?$' +)] +param( +# The object to write. +[Alias('Input','Content','Text')] +[PSObject] +$InputObject, + +<# + +Any options used to write the object + +Supported Options: + +|Option|Description| +|-|-| +|Depth|The serialization depth| +|Encoding|The text encoding| +|Stream|Optional destination stream| + +#> +[Collections.IDictionary] +$Option = [Ordered]@{} +) + +# If this object does not have a write text method, return. +if (-not $this.WriteText) { throw 'No `.WriteText()`'; return } + +# If no depth was set, +if (-not $option.Depth) { + # use double the format enumeration limit (by default, 8) + $option.Depth = $FormatEnumerationLimit * 2 +} + +# If we have a .Package and .PartUri property +if ($InputObject.Package -is [IO.Packaging.Package] -and + $InputObject.PartUri -is [uri]) { + # avoid putting them in the object + $text = @(foreach ($in in $InputObject | + Select-Object -Property * -ExcludeProperty 'Package', 'PartUri') { + ConvertTo-Json -InputObject $in -Depth $Option.Depth -Compress + }) -join [Environment]::NewLine +} else { + # Convert any other objects to json. + $text = @(foreach ($in in $InputObject) { + ConvertTo-Json -InputObject $in -Depth $Option.Depth -Compress + }) -join [Environment]::NewLine + +} + +# Then, write the text +$this.WriteText($text, $Option) \ No newline at end of file diff --git a/Types/OpenPackage.Part/WritePowerShell.ps1 b/Types/OpenPackage.Part/WritePowerShell.ps1 new file mode 100644 index 0000000..a9247a6 --- /dev/null +++ b/Types/OpenPackage.Part/WritePowerShell.ps1 @@ -0,0 +1,97 @@ +<# +.SYNOPSIS + Writes Part Content as PowerShell +.DESCRIPTION + Reads Open Package Part Content as PowerShell +.NOTES + This may attempt to convert the input into a ScriptBlock. + + It will not write content if the conversion fails. + + If a function is provided as input, will write a script block that declares that function. +#> +[Reflection.AssemblyMetadata( + # This should automatically apply to `.ps1` and `.psm1` files + 'FilePattern', + '\.psm?1$' +)] +param( +# The object to write. +[Alias('Input','Content','Text')] +[PSObject] +$InputObject, + +<# + +Any options used to write the object + +Commonly Supported Options: + +|Option|Description| +|-|-| +|Depth|The serialization depth| +|Encoding|The text encoding| +|Stream|Optional destination stream| + +#> +[Alias('Options')] +[Collections.IDictionary] +$Option = [Ordered]@{} +) + +# If this object does not have a write text method, return. +if (-not $this.WriteText) { throw 'No `.WriteText()`'; return } + +# If the input is a scriptblock +if ($inputObject -is [ScriptBlock]) { + # write it and return. + $this.WriteText("$InputObject", $Option) + return +} + +# If the input was an external script +if ($inputObject -is [Management.Automation.ExternalScriptInfo]) { + # write its script block and return + $this.WriteText("$($InputObject.ScriptBlock)", $Option) + return +} + +# If the input is a function, recreate the function +if ($inputObject -is [Management.Automation.FunctionInfo]) { + # First check for exotically named commands. + if ($inputObject.Name -match '[\s\{\}\(\)]') { + # We can use the function: drive to set them (but only functions). + if ($inputObject.CommandType -eq 'function') { + $this.WriteText(@( + "`$executionContext.SessionState.PSVariable.Set(" + " 'function:$($inputObject.Name -replace "'", "''")',{" + $InputObject.ScriptBlock + "})" + ) -join [Environment]::NewLine) + } else { + Write-Error "Can not recreated $($InputObject.Name) as a filter" + } + } else { + # If the command was not exotically named, + # we can just embed it directly + $this.WriteText(@( + "$($inputObject.CommandType) $($inputObject.Name) {" + $InputObject.ScriptBlock + "}" + ) -join [Environment]::NewLine, $Option) + } + + return +} + +# If the input object was not a ScriptBlock, Function, or ExternalScript +# try to cast the input into a script. +# If this fails, it will output an error +$scriptBlock = [ScriptBlock]::Create("$InputObject") + +# If it succeeded, write the text +if ($scriptBlock -is [scriptblock]) { + $this.WriteText("$scriptBlock", $Option) +} +# Either way, we're done. +return \ No newline at end of file diff --git a/Types/OpenPackage.Part/WriteText.ps1 b/Types/OpenPackage.Part/WriteText.ps1 new file mode 100644 index 0000000..49989de --- /dev/null +++ b/Types/OpenPackage.Part/WriteText.ps1 @@ -0,0 +1,98 @@ +<# +.SYNOPSIS + Writes Part Content as Text +.DESCRIPTION + Writes Package Part Content as Text +.NOTES + +#> +[Reflection.AssemblyMetadata( + # This should automatically apply to .txt files, modelfiles, and dockerfiles. + 'FilePattern', + '(?>[/\.]Dockerfile|[/\.]Modelfile|\.txt|\.svg|\.xml)$' +)] +[Reflection.AssemblyMetadata( + # This should automatically apply to any text/ content types + 'ContentTypePattern', + '^text/' +)] +[Reflection.AssemblyMetadata( + # This should automatically apply to any xml content types + 'ContentTypePattern', + '[/\+]xml' +)] +[Reflection.AssemblyMetadata( + # This should automatically apply to any json content types + 'ContentTypePattern', + '[/\+]json' +)] +[Reflection.AssemblyMetadata( + # This has a higher order, indicating it should be run later than most. + 'Order', + 100 +)] +param( +# The object to write. +[Alias('Input','Content','Text')] +[PSObject] +$InputObject, + +<# + +Any options used to write the object + +Supported Options: + +|Option|Description| +|-|-| +|Encoding|The text encoding| +|Stream|Optional destination stream| +#> + +[Collections.IDictionary] +$Option = [Ordered]@{} +) + +# If there is no part, return +$part = $this +if (-not $part) { return } + +if (-not $option.Encoding) { + $option.Encoding = [Text.Encoding]::UTF8 +} + + +if ( + # If we provide an optional stream + $option.Stream -is [IO.Stream] -and + # and it is writeable + $option.Stream.CanWrite +) { + # Then we will write to the stream. + + # Get the bytes we will write + $bytes = $Option.Encoding.GetBytes("$InputObject") + # and write them to the stream. + $option.Stream.Write($bytes,0, $bytes.Length) + # * The caller gave us the stream + # * The caller should dispose of it sometime. + # We can just return. + return +} + +# If no `-Option @{Stream}` was passed +# Then we are writing to this part (or trying to). +$partStream = $part.GetStream('Open','ReadWrite') +# If we can not write, return now. +if (-not $partStream) { return } + +# Otherwise, zero out the length +$partStream.SetLength(0) + +# Get the bytes we will write +$bytes = $Option.Encoding.GetBytes("$InputObject") + +# Write the bytes to the stream, +$partStream.Write($bytes, 0, $bytes.Length) +$partStream.Close() # close, +$partStream.Dispose() # and dispose. \ No newline at end of file diff --git a/Types/OpenPackage.Part/WriteToml.ps1 b/Types/OpenPackage.Part/WriteToml.ps1 new file mode 100644 index 0000000..10211b0 --- /dev/null +++ b/Types/OpenPackage.Part/WriteToml.ps1 @@ -0,0 +1,71 @@ +<# +.SYNOPSIS + Writes Part Content as Toml +.DESCRIPTION + Writes Open Package Part Content as Tom's Obvious Minimal Language +.LINK + https://toml.io/ +.LINK + https://github.com/jborean93/PSToml +.LINK + https://www.powershellgallery.com/packages/PSToml/ +#> +[Reflection.AssemblyMetadata( + # This should automatically apply to .yaml files + 'FilePattern', + '\.toml$' +)] +param( +# The object to write. +[Alias('Input','Content','Text')] +[PSObject] +$InputObject, + +<# + +Any options used to write the object + +Supported Options: + +|Option|Description| +|-|-| +|Depth|The serialization depth| +|Encoding|The text encoding| +|Stream|Optional destination stream| + +#> +[Collections.IDictionary] +$Option = [Ordered]@{} +) + +# If this object does not have a write text method, return. +if (-not $this.WriteText) { throw 'No `.WriteText()`'; return } + +# If no depth was set, +if (-not $option.Depth) { + # use double the format enumeration limit (by default, 8) + $option.Depth = $FormatEnumerationLimit * 2 +} + +$ConvertToToml = $executionContext.SessionState.InvokeCommand.GetCommand('ConvertTo-Toml', 'Cmdlet,Function') + +if (-not $ConvertToToml) { + Write-Error "ConvertTo-Toml not found, please install PSToml" + return +} + +# If we have a .Package and .PartUri property +if ($inputObject.Package -is [IO.Packaging.Package] -and + $inputObject.PartUri -is [uri]) { + # avoid putting them in the object + $text = & $ConvertToToml -InputObject ( + $inputObject | + Select-Object -Property * -ExcludeProperty 'Package', 'PartUri' + ) -Depth $Option.Depth +} else { + # Convert any other objects to json. + $text = & $ConvertToToml -InputObject $inputObject -Depth $Option.Depth +} + +if (-not $text) { return } +$this.WriteText($text, $Option) \ No newline at end of file diff --git a/Types/OpenPackage.Part/WriteXml.ps1 b/Types/OpenPackage.Part/WriteXml.ps1 new file mode 100644 index 0000000..1f646ae --- /dev/null +++ b/Types/OpenPackage.Part/WriteXml.ps1 @@ -0,0 +1,53 @@ +<# +.SYNOPSIS + Writes Part Content XML +.DESCRIPTION + Writes an OpenPackage Part's Content as XML +#> +[Reflection.AssemblyMetadata( + 'FilePattern', '\.(?>svg|nuspec|ps1xml|xml|xhtml|proj)$' +)] +[Reflection.AssemblyMetadata( + 'ContentTypePattern', '[/\+](?>xml|xsd|xslt?)$' +)] +param( +# The object to write. +[Alias('Input','Content','Text')] +[PSObject] +$InputObject, + +<# + +Any options used to write the object + +Supported Options: + +|Option|Description| +|-|-| +|Encoding|The text encoding| +|Stream|Optional destination stream| +#> + +[Collections.IDictionary] +$Option = [Ordered]@{} +) + +if (-not $this.WriteText) { throw 'No `.WriteText()`'; return } + +# If the content is aleady xml +if ($InputObject -is [xml]) { + # write the content + $this.WriteText($InputObject.OuterXml, $option) + return +} + +# Otherwise, make the content a string array and join it with nothing +$stringified = $InputObject -as 'string[]' -join '' +# this will coalesce output into a form that might be xml. +# By casting, we will see any error in conversion. +$contentAsXml = [xml]$stringified +# If conversion to xml worked, +if ($contentAsXml) { + $this.WriteText($stringified, $option) # write our string + return +} \ No newline at end of file diff --git a/Types/OpenPackage.Part/WriteYaml.ps1 b/Types/OpenPackage.Part/WriteYaml.ps1 new file mode 100644 index 0000000..5261dd3 --- /dev/null +++ b/Types/OpenPackage.Part/WriteYaml.ps1 @@ -0,0 +1,69 @@ +<# +.SYNOPSIS + Writes Part Content as Yaml +.DESCRIPTION + Writes Open Package Part Content as Yaml +.LINK + https://github.com/jborean93/YaYaml +.LINK + https://www.powershellgallery.com/packages/YaYaml/ +#> +[Reflection.AssemblyMetadata( + # This should automatically apply to .yaml files + 'FilePattern', + '\.ya?ml$' +)] +param( +# The object to write. +[Alias('Input','Content','Text')] +[PSObject] +$InputObject, + +<# + +Any options used to write the object + +Supported Options: + +|Option|Description| +|-|-| +|Depth|The serialization depth| +|Encoding|The text encoding| +|Stream|Optional destination stream| + +#> +[Collections.IDictionary] +$Option = [Ordered]@{} +) + +# If this object does not have a write text method, return. +if (-not $this.WriteText) { throw 'No `.WriteText()`'; return } + +# If no depth was set, +if (-not $option.Depth) { + # use double the format enumeration limit (by default, 8) + $option.Depth = $FormatEnumerationLimit * 2 +} + +$ConvertToYaml = $executionContext.SessionState.InvokeCommand.GetCommand('ConvertTo-Yaml', 'Cmdlet,Function') + +if (-not $ConvertToYaml) { + Write-Error "ConvertTo-Yaml not found, please install YaYaml" + return +} + +# If we have a .Package and .PartUri property +if ($inputObject.Package -is [IO.Packaging.Package] -and + $inputObject.PartUri -is [uri]) { + # avoid putting them in the object + $text = & $ConvertToYaml -InputObject ( + $inputObject | + Select-Object -Property * -ExcludeProperty 'Package', 'PartUri' + ) -Depth $Option.Depth +} else { + # Convert any other objects to json. + $text = & $ConvertToYaml -InputObject $inputObject -Depth $Option.Depth +} + +if (-not $text) { return } +$this.WriteText($text, $Option) \ No newline at end of file diff --git a/Types/OpenPackage.Part/get_Culture.ps1 b/Types/OpenPackage.Part/get_Culture.ps1 new file mode 100644 index 0000000..3bb0e80 --- /dev/null +++ b/Types/OpenPackage.Part/get_Culture.ps1 @@ -0,0 +1,18 @@ +<# +.SYNOPSIS + Gets the culture of the part. +.DESCRIPTION + Gets the `[CultureInfo]` associated with a package part. +#> +param() +# Get all the culture names +$allCultures = [cultureinfo]::GetCultures('all').name + +# Split the uri into segments +foreach ($segment in $this.Uri -split '/' -ne '') { + # if any segment is a culture name + if ($segment -in $allCultures) { + $segment -as [cultureinfo] + # it applies to that segment. + } +} diff --git a/Types/OpenPackage.Part/get_Extension.ps1 b/Types/OpenPackage.Part/get_Extension.ps1 new file mode 100644 index 0000000..68fe98a --- /dev/null +++ b/Types/OpenPackage.Part/get_Extension.ps1 @@ -0,0 +1,15 @@ +<# +.SYNOPSIS + Gets a part extensions +.DESCRIPTION + Gets the file extension of a package part. + + This is anything after the last `.` in the part uri. +#> +param() + +# If there is no uri, there is no extension +if (-not $this.Uri) { return } + +# Split by periods, ignore any blanks, and return the last segment. +@($this.Uri -split '\.' -ne '')[-1] \ No newline at end of file diff --git a/Types/OpenPackage.Part/get_GitDate.ps1 b/Types/OpenPackage.Part/get_GitDate.ps1 new file mode 100644 index 0000000..ddf9c31 --- /dev/null +++ b/Types/OpenPackage.Part/get_GitDate.ps1 @@ -0,0 +1,37 @@ +<# +.SYNOPSIS + Gets Package Part Git Dates +.DESCRIPTION + Gets any Git commit Dates associated with a package part. + + This must be run from within a git repository, + and the part source file must exist in the repository. +.NOTES + The package must have two relationships for this to work: + + 1. A `git` `repository`, to indicate the git repository + 2. A `source` `directory`, to indicate the source directory. +#> + +if (-not $this.Package.RelationshipExists) { return } +$repoExists = $this.Package.RelationshipExists('repository') +if (-not $repoExists) { return } + +$sourceExists = $this.Package.RelationshipExists('directory') +if (-not $sourceExists) { return } + +$gitApp = $executionContext.SessionState.InvokeCommand.GetCommand('git', 'application') + +if (-not $gitApp) { return } + +$gitRoot = try { & $gitApp rev-parse --show-toplevel *&>1} catch {$LASTEXITCODE = 0} + +$directory = Get-Item -LiteralPath $gitRoot -ErrorAction Ignore +if (-not $directory) { return } + +$filePath = Join-Path $directory $this.Uri +$file = Get-Item -LiteralPath $filePath -ErrorAction Ignore + +if (-not $file) { return } + +@(& $gitApp log --follow --format=%ci --date default $file.FullName *>&1) -as [datetime[]] diff --git a/Types/OpenPackage.Part/get_Hash.ps1 b/Types/OpenPackage.Part/get_Hash.ps1 new file mode 100644 index 0000000..13e9169 --- /dev/null +++ b/Types/OpenPackage.Part/get_Hash.ps1 @@ -0,0 +1,7 @@ +<# +.SYNOPSIS + Gets the part hash +.DESCRIPTION + Gets the part hash as a string +#> +$this.GetHash().Hash \ No newline at end of file diff --git a/Types/OpenPackage.Part/get_IsEponym.ps1 b/Types/OpenPackage.Part/get_IsEponym.ps1 new file mode 100644 index 0000000..8004e6c --- /dev/null +++ b/Types/OpenPackage.Part/get_IsEponym.ps1 @@ -0,0 +1,39 @@ +<# +.SYNOPSIS + Determines if a part is an eponym +.DESCRIPTION + Eponyms are files whose name matches their directory. + + For example: + + `/foo/foo.ps1` is an eponym + `/foo/foo.md` is an eponym + `/foo/bar.ps1` is not an eponym + + If a package has an an identifier, + any files whose name matches this identifier will be considered an eponym. +.NOTES + Multiple eponyms may exist for a given path. + + Anything before the first period `.` will be considered the name of the file. +#> +param() +$part = $this + +$partSegments = @($part.Uri -split '/+' -ne '') + +if ($partSegments.Count -eq 1 -and $this.Package.Identifier) { + if ($partSegments[0] -replace '\..+$' -eq $this.Package.Identifier) { + return $true + } +} + +if ($partSegments.Count -ge 2) { + if ($partSegments[-2] -eq + ($partSegments[-1] -replace '\..+$') + ) { + return $true + } +} + +return $false \ No newline at end of file diff --git a/Types/OpenPackage.Part/get_Metadata.ps1 b/Types/OpenPackage.Part/get_Metadata.ps1 new file mode 100644 index 0000000..7935fa0 --- /dev/null +++ b/Types/OpenPackage.Part/get_Metadata.ps1 @@ -0,0 +1,82 @@ +<# +.SYNOPSIS + Gets Part Metadata +.DESCRIPTION + Gets Part Metadata from parts with similar names +.EXAMPLE + $package.GetPart("/foo").Metadata +.NOTES + Parts can get metadata from many places. + + This will get metadata in a cascade. + + First, it will get any metadata found in a related data file: + + For example, `/foo/bar.md` could have metadata in: + + * `/foo/bar.md.json` + * `/foo/bar.md.psd1` + * `/foo/bar.md.toml` + * `/foo/bar.md.xml` + * `/foo/bar.md.yaml` + + * `/foo.json` + * `/foo.psd1` + * `/foo.toml` + * `/foo.xml` + * `/foo.yaml` + +#> +param() + +# For the moment, we will limit the types of files we can process as metadata. +$dataFilePattern = '\.(?>json|psd1|toml|xml|ya?ml)$' + +# First, match any directly related files. +$directlyRelated = "^$([Regex]::Escape("$($this.Uri)"))$dataFilePattern" + +# Check each part in the package +foreach ($part in $this.Package.Parts) { + # if it is directly related, and readable, read it. + if ($part.Uri -notmatch $directlyRelated) { continue } + if ($part.Reader) { + try { + $part.Read() + } catch { + Write-Warning "Error Reading $($part.Uri): $_" + } + } +} + +# Now let's go up thru the list of segments +$segments = @($this.Uri -split '/' -ne '') +# Go backwards to forwards (bottom-most path to topmost path) +for ($segmentNumber = $segments.Count - 1; $segmentNumber -ge 0; $segmentNumber--) { + # If we're at the topmost path and have an identifier + $relatedToParent = if ($segmentNumber -eq 0 -and $this.Package.Identifier) { + # Look for files related to the identifier. + "/$([Regex]::Escape($this.Package.Identifier))$dataFilePattern" + } elseif ($segmentNumber -gt 0) { + # If we're at any greater index, join all segments together + "/$([Regex]::Escape( + @( + $segments[0..$segmentNumber]; + # and repeat the last segment. + $segments[$segmentNumber] + ) -join '/' + ))$dataFilePattern$" + } + + # Now go thru all of the parts in the package + foreach ($part in $this.Package.Parts) { + # and find any related to this level of parent. + if ($part.Uri -match $relatedToParent -and $part.Reader) { + try { + # and read the metadata. + $part.Read() + } catch { + Write-Warning "Error Reading $($part.Uri): $_" + } + } + } +} \ No newline at end of file diff --git a/Types/OpenPackage.Part/get_Name.ps1 b/Types/OpenPackage.Part/get_Name.ps1 new file mode 100644 index 0000000..bccba66 --- /dev/null +++ b/Types/OpenPackage.Part/get_Name.ps1 @@ -0,0 +1,10 @@ +<# +.SYNOPSIS + Gets the part name +.DESCRIPTION + Gets the part name. + + This is is anything before the first period (`.`). +#> +param() +@($this.Uri -split '/' -ne '')[-1] -replace '\..+?$' \ No newline at end of file diff --git a/Types/OpenPackage.Part/get_Palette.ps1 b/Types/OpenPackage.Part/get_Palette.ps1 new file mode 100644 index 0000000..f108087 --- /dev/null +++ b/Types/OpenPackage.Part/get_Palette.ps1 @@ -0,0 +1,18 @@ +<# +.SYNOPSIS + Gets an Open Package Part's palette +.DESCRIPTION + Gets an Open Package palette for a part +.NOTES + A palette is a relationship between a package and a stylesheet. +#> +param() + +if (-not $this.RelationshipExists -or + -not $this.GetRelationship) { + return +} + +if ($this.RelationshipExists('palette')) { + return $this.GetRelationship('palette').TargetUri +} \ No newline at end of file diff --git a/Types/OpenPackage.Part/get_Reader.ps1 b/Types/OpenPackage.Part/get_Reader.ps1 new file mode 100644 index 0000000..507369e --- /dev/null +++ b/Types/OpenPackage.Part/get_Reader.ps1 @@ -0,0 +1,94 @@ +<# +.SYNOPSIS + Gets a Part's available Readers +.DESCRIPTION + Gets the different methods we can use to Read a part. +#> +param() + +# If we already have a cached list of Readers +if ($this.'#Readers') { + # return it. + return $this.'#Readers' +} + +# To get all of our methods, go over each method on this object +$ReadMethods = @(:nextMethod foreach ($method in $this.PSObject.Methods) { + # and ignore any methods that do not start with `Read`. + if ($method.Name -notmatch 'Read.+?') { + continue + } + # If there is no script, ignore the method. + if (-not $method.Script) { continue } + $script = $method.Script + # If there are no attributes, ignore the method + if (-not $script.Attributes) { continue } + # Gather all of our attribute data + $attributeData = [Ordered]@{} + :nextAttribute foreach ($attribute in $script.Attributes) { + # If the attribute does not have a key and value, + if (-not ($attribute.Key -and $attribute.value)) { + continue nextAttribute # continue to the next attribute + } + + # If we do not already know about this key + if (-not $attributeData[$attribute.key]) { + # set it + $attributeData[$attribute.key] = $attribute.value + } else { + # otherwise, make our data a list and add the new value + $attributeData[$attribute.key] = @( + $attributeData[$attribute.key] + ) + $attribute.Value + } + } + + # Now we want to try to match. + try { + # If either the `FilePattern` or `ContentTypePattern` matches + # we consider this to be a potential Reader. + + # Loop thru the file patterns + foreach ($pattern in $attributeData.FilePattern) { + # skip parts that do not match. + if ($this.Uri -notmatch $pattern) { continue } + # Output the matching method + $method + continue nextMethod # and continue to the next method + } + # Loop thru the content type patterns + foreach ($pattern in $attributeData.ContentTypePattern) { + # skip parts that do not match. + if ($this.ContentType -notmatch $pattern) { continue } + # Output the matching method + $method + continue nextMethod # and continue to the next method. + } + } catch { + Write-Debug "$_" + } +}) + +# Now that we know all of the methods, we need to put them in order. +$ReadMethods = @( + $ReadMethods | # We can do this with a dynamic sort. + Sort-Object { + $in = $_ + # Simply walk over each attribute + foreach ($attr in $in.Script.Attributes) { + # any named `Order` with an integer value + if ($attr.Key -eq 'Order' -and $attr.Value -as [int]) { + # will use that value + return ($attr.Value -as [int]) + } + } + # Without an order attribute, use zero + return 0 + }, Name # and perform a secondary sort by name. +) + +# Now that we've gotten our Readers and sorted them +# cache them on the object. +$this | Add-Member NoteProperty '#Readers' $ReadMethods -Force +# and return the cached value. +return $this.'#Readers' \ No newline at end of file diff --git a/Types/OpenPackage.Part/get_Related.ps1 b/Types/OpenPackage.Part/get_Related.ps1 new file mode 100644 index 0000000..8141254 --- /dev/null +++ b/Types/OpenPackage.Part/get_Related.ps1 @@ -0,0 +1,19 @@ +<# +.SYNOPSIS + Get Related Part information +.DESCRIPTION + Get Package Part and Package Relationships +.NOTES + This will`.GetRelationships()` from the current part, + then `.GetRelationships()` from the package. +#> +if (-not $this.GetRelationships) { + return +} + +@( + $this.GetRelationships() + if ($this.Package -is [IO.Packaging.Package]) { + $this.Package.GetRelationships() + } +) diff --git a/Types/OpenPackage.Part/get_Writer.ps1 b/Types/OpenPackage.Part/get_Writer.ps1 new file mode 100644 index 0000000..65def82 --- /dev/null +++ b/Types/OpenPackage.Part/get_Writer.ps1 @@ -0,0 +1,94 @@ +<# +.SYNOPSIS + Gets a Part's available writers +.DESCRIPTION + Gets the different methods we can use to Write to a part. +#> +param() + +# If we already have a cached list of writers +if ($this.'#Writers') { + # return it. + return $this.'#Writers' +} + +# To get all of our methods, go over each method on this object +$WriteMethods = @(:nextMethod foreach ($method in $this.PSObject.Methods) { + # and ignore any methods that do not start with `Write`. + if ($method.Name -notmatch 'Write.+?') { + continue + } + # If there is no script, ignore the method. + if (-not $method.Script) { continue } + $script = $method.Script + # If there are no attributes, ignore the method + if (-not $script.Attributes) { continue } + # Gather all of our attribute data + $attributeData = [Ordered]@{} + :nextAttribute foreach ($attribute in $script.Attributes) { + # If the attribute does not have a key and value, + if (-not ($attribute.Key -and $attribute.value)) { + continue nextAttribute # continue to the next attribute + } + + # If we do not already know about this key + if (-not $attributeData[$attribute.key]) { + # set it + $attributeData[$attribute.key] = $attribute.value + } else { + # otherwise, make our data a list and add the new value + $attributeData[$attribute.key] = @( + $attributeData[$attribute.key] + ) + $attribute.Value + } + } + + # Now we want to try to match. + try { + # If either the `FilePattern` or `ContentTypePattern` matches + # we consider this to be a potential writer. + + # Loop thru the file patterns + foreach ($pattern in $attributeData.FilePattern) { + # skip parts that do not match. + if ($this.Uri -notmatch $pattern) { continue } + # Output the matching method + $method + continue nextMethod # and continue to the next method + } + # Loop thru the content type patterns + foreach ($pattern in $attributeData.ContentTypePattern) { + # skip parts that do not match. + if ($this.ContentType -notmatch $pattern) { continue } + # Output the matching method + $method + continue nextMethod # and continue to the next method. + } + } catch { + Write-Debug "$_" + } +}) + +# Now that we know all of the methods, we need to put them in order. +$WriteMethods = @( + $WriteMethods | # We can do this with a dynamic sort. + Sort-Object { + $in = $_ + # Simply walk over each attribute + foreach ($attr in $in.Script.Attributes) { + # any named `Order` with an integer value + if ($attr.Key -eq 'Order' -and $attr.Value -as [int]) { + # will use that value + return ($attr.Value -as [int]) + } + } + # Without an order attribute, use zero + return 0 + }, Name # and perform a secondary sort by name. +) + +# Now that we've gotten our writers and sorted them +# cache them on the object. +$this | Add-Member NoteProperty '#Writers' $WriteMethods -Force +# and return the cached value. +return $this.'#Writers' \ No newline at end of file diff --git a/Types/OpenPackage.Part/set_Palette.ps1 b/Types/OpenPackage.Part/set_Palette.ps1 new file mode 100644 index 0000000..02c7b68 --- /dev/null +++ b/Types/OpenPackage.Part/set_Palette.ps1 @@ -0,0 +1,23 @@ +<# +.SYNOPSIS + Sets an Open Package Part's palette +.DESCRIPTION + Sets an Open Package palette for a part. +.NOTES + A palette is a relationship between a package and a stylesheet. +#> +param( +[Parameter(Mandatory)] +[ValidatePattern('.css$')] +[uri]$Palette +) +if (-not $this.RelationshipExists) { + return +} +if ($this.RelationshipExists('palette')) { + $this.DeleteRelationship('palette') + $this.CreateRelationship($Palette, 'external', 'stylesheet', 'palette') +} else { + $this.CreateRelationship($Palette, 'external', 'stylesheet', 'palette') +} + diff --git a/Types/OpenPackage.Publisher/Alias.psd1 b/Types/OpenPackage.Publisher/Alias.psd1 new file mode 100644 index 0000000..b37c084 --- /dev/null +++ b/Types/OpenPackage.Publisher/Alias.psd1 @@ -0,0 +1,7 @@ +@{ + "11ty" = "Eleventy" + 'atSession' = 'com.atproto.server.createSession' + 'atRecord' = 'com.atproto.repo.createRecord' + 'atBlob' = 'com.atproto.repo.uploadBlob' + 'GitTags' = 'GitTag' +} \ No newline at end of file diff --git a/Types/OpenPackage.Publisher/Eleventy.ps1 b/Types/OpenPackage.Publisher/Eleventy.ps1 new file mode 100644 index 0000000..0e256de --- /dev/null +++ b/Types/OpenPackage.Publisher/Eleventy.ps1 @@ -0,0 +1,59 @@ +<# +.SYNOPSIS + Publishes a static site using eleventy +.DESCRIPTION + Publishes a package as a static site, using eleventy +.NOTES + This installs the package to the -DestinationPath (default `./_site`) + + Then, this runs eleventy using npx +.LINK + https://11ty.dev/docs +#> +[CmdletBinding(PositionalBinding=$false,SupportsShouldProcess)] +param( +# The destination path of the website. +[Parameter(Position=0)] +[string] +$DestinationPath = './_site', + +# Any input object. This should be one or more packages. +# If no input is provided, will archive the current directory and publish a page. +[Parameter(ValueFromPipeline)] +[Alias('Package')] +[PSObject[]] +$InputObject +) + +# Quickly enumerate all input and arguments. +$allInput = @($input) +if (-not $allInput -and $InputObject) { + $allInput += $InputObject +} + +# If there is no input +if (-not $allInput) { + # exclude our destination from the input + $excludeWildCard = $DestinationPath -replace './', '*' -replace '$', '*' + # and make a package from the current directory. + $allInput = @(Get-OpenPackage -FilePath . -Exclude $excludeWildCard) +} + +# Still no input? Return. +if (-not $allInput) { return } + +# To build a static site out of a package, we just need to install it +$installParameters = [Ordered]@{ + DestinationPath = $DestinationPath + Force = $true + WarningAction = 'SilentlyContinue' + WarningVariable = 'warnings' +} + +# All of the content from that site is the base layer +$allInput | + Install-OpenPackage @installParameters -PassThru -Force + +Push-Location $DestinationPath +npx '@11ty/eleventy' +Pop-Location \ No newline at end of file diff --git a/Types/OpenPackage.Publisher/GitHubRelease.ps1 b/Types/OpenPackage.Publisher/GitHubRelease.ps1 new file mode 100644 index 0000000..e37cd14 --- /dev/null +++ b/Types/OpenPackage.Publisher/GitHubRelease.ps1 @@ -0,0 +1,224 @@ +<# +.SYNOPSIS + Publishes Releases to GitHub +.DESCRIPTION + Publishes a Release to GitHub, if the version does not already exist. +#> +[CmdletBinding(SupportsShouldProcess,ConfirmImpact='High')] +param( +# The package +[Parameter(Mandatory,ValueFromPipeline)] +[IO.Packaging.Package] +$Package, + +# One or more release asssets. These are files that are attached to the package. +[string[]] +$ReleaseAsset, + +# The GitHub Token used to publish the release. +# If there is no GitHubEvent, this is presumed to be a Personal Access Token. +[string] +$GitHubToken = $env:GH_TOKEN, + +# The GitHubOwner. +# If running in a workflow, this should be automatically detected. +# If running outside of a workflow, this must be provided. +[string] +$GitHubOwner = $env:GITHUB_OWNER, + +# The GitHub Repository, in the form of `owner/repo` +# If running in a workflow, this should be automatically detected. +# If running outside of a workflow, this must be provided. +[string] +$GitHubRepository = $env:GITHUB_REPOSITORY +) + +# If there is no package version, throw +if (-not $package.Version) { throw "No Package Version" } + +# If the package has no repository +if (-not $package.RelationshipExists('repository')) { + throw "Package not related to repository" # throw +} + +# Get the git app (as opposed to any git functions) +$gitApp = $ExecutionContext.SessionState.InvokeCommand.GetCommand('git', 'application') +# If we could not get git +if (-not $gitApp) { + throw "No Git" # throw. +} + +# Get the repository remote url +$repositoryUrl = @(& $gitApp remote)[0] | + ForEach-Object { + & $gitApp remote get-url $_ + } + +# and throw if we could not +if (-not $repositoryUrl) { + throw "No Repository" +} + +# Make sure this package is related to the repository +$packageGitRepo = $package.GetRelationship('repository').TargetUri +if ($packageGitRepo -ne $repositoryUrl) { + # and throw if that is not the case. + throw "Package unrelated to '$repositoryUrl'" +} + + +# Get the github event +$gitHubEvent = if ($env:GITHUB_EVENT_PATH) { + [IO.File]::ReadAllText($env:GITHUB_EVENT_PATH) | ConvertFrom-Json +} else { $null } + +# Warn about confirmation if we do not have one +if (-not $gitHubEvent) { + Write-Warning "No github event found, prompting for confirmation" +} else { + # If the event is not a merg, warn and return + if (-not ($gitHubEvent.head_commit.message -match "Merge Pull Request #(?\d+)") -and + (-not $gitHubEvent.psobject.properties['inputs'])) { + "::warning::Pull Request has not merged, skipping github release" | Out-Host + return + } +} + +# Find the target version +$targetVersion = "v$($package.Version)" + +# Go get all of our releases +$releasesURL = "https://api.github.com/repos/$GitHubRepository/releases" + +# List out our source if we are running in a workflow. +if ($gitHubEvent) { + "Release URL: $releasesURL" | Out-Host +} + +# Go get the releases. +$listOfReleases = + Invoke-RestMethod -Uri $releasesURL -Method Get -Headers @{ + "Accept" = "application/vnd.github.v3+json" + "Authorization" = + if ($gitHubEvent) { + "Bearer $GitHubToken" + } elseif ($GitHubToken) { + "Basic $( + [System.Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$GitHubToken")) + )" + } + + } + +# and see if ours already exists +$releaseExists = $listOfReleases | Where-Object tag_name -eq $targetVersion + +# If it does +if ($releaseExists) { + + if ($gitHubEvent) { + "::warning::Release '$($releaseExists.Name )' Already Exists" | Out-Host + } else { + Write-Warning "'$($releaseExists.Name )' Already Exists" + } + + # store this to a variable so that we may potentially add assets. + $releasedIt = $releaseExists +} else { + # If no release exists yet, + # prepare our parameters + $releaseParameters = [Ordered]@{ + Uri = $releasesURL + Method = 'POST' + Body = [Ordered]@{ + owner = "$gitHubOwner" + repo = "$GitHubRepository" + tag_name = $targetVersion + name = "$($Package.Identifier) $($package.Version)" + body = + if ($env:RELEASENOTES) { + $env:RELEASENOTES + } elseif ($package.PowerShellManifest.PrivateData.PSData.ReleaseNotes) { + $package.PowerShellManifest.PrivateData.PSData.ReleaseNotes + } else { + "$($package.Identifier) $targetVersion" + } + draft = + if ($env:RELEASEISDRAFT) { [bool]::Parse($env:RELEASEISDRAFT) } else { $false } + prerelease = + if ($env:PRERELEASE) { [bool]::Parse($env:PRERELEASE) } else { $false } + } + } + + # If -WhatIf was passed, return the release parameters + if ($whatIfPreference) { + return $releaseParameters + } + + # If there is no github event, prompt before releasing. + if (-not $gitHubEvent -and -not $psCmdlet.ShouldProcess("Release $($releaseParameters.body.name)")) { + return + } + + # Create the release + $releaseParameters.body = $releaseParameters.body | ConvertTo-Json -Depth 10 + $ReleaseHeaders = @{ + "Accept" = "application/vnd.github.v3+json" + "Content-type" = "application/json" + "Authorization" = if ($gitHubEvent) { + "Bearer $GitHubToken" + } else { + "Basic $( + [System.Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$GitHubToken")) + )" + } + } + + $releasedIt = Invoke-RestMethod @releaseParameters -Headers $releaseHeaders +} + +if (-not $releasedIt) { + throw "Release failed" +} else { + $releasedIt | Out-Host +} + + + +if ($ReleaseAsset) { + $releaseUploadUrl = $releasedIt.upload_url -replace '\{.+$' + + $filesToRelease = + Get-ChildItem -File -Path $ReleaseAsset -ErrorAction Ignore + + $releasedFiles = @{} + foreach ($file in $filesToRelease) { + if ($releasedFiles[$file.Name]) { + Write-Warning "Already attached file $($file.Name)" + continue + } + else { + # If there is no github event, prompt before attaching. + if (-not $gitHubEvent -and -not $psCmdlet.ShouldProcess("Attach $($file.name)")) { + continue + } + $fileBytes = [IO.File]::ReadAllBytes($file.FullName) + $releasedFiles[$file.Name] = + Invoke-RestMethod -Uri "${releaseUploadUrl}?name=$($file.Name)" -Headers @{ + "Accept" = "application/vnd.github+json" + "Authorization" = if ($GitHubEvent) { + "Bearer $GitHubToken" + } else { + "Basic $( + [System.Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$GitHubToken")) + )" + } + } -Body $fileBytes -ContentType Application/octet-stream + $releasedFiles[$file.Name] + } + } + + if ($gitHubEvent) { + "Attached $($releasedFiles.Count) file(s) to release" | Out-Host + } +} \ No newline at end of file diff --git a/Types/OpenPackage.Publisher/GitTag.ps1 b/Types/OpenPackage.Publisher/GitTag.ps1 new file mode 100644 index 0000000..0fe4f00 --- /dev/null +++ b/Types/OpenPackage.Publisher/GitTag.ps1 @@ -0,0 +1,123 @@ +<# +.SYNOPSIS + Publishes Git Tags +.DESCRIPTION + Publishes Git Tags if the version has changed. + + This is required to create releases on github. +.NOTES + Must provide a single package to publish. + + Must be run from the repository in question. +#> +[CmdletBinding(SupportsShouldProcess,ConfirmImpact='High')] +param( +[Parameter(Mandatory,ValueFromPipeline)] +[IO.Packaging.Package] +$Package +) + +# Throw if there is no version +if (-not $package.Version) { throw "No Package Version" } + +# Throw if there is no repository relationship +if (-not $package.RelationshipExists('repository')) { + throw "Package not related to repository" +} + +# Get the gip application (no to be confused with any functions named git) +$gitApp = $ExecutionContext.SessionState.InvokeCommand.GetCommand('git', 'application') + +# Find our repository url. +$repositoryUrl = @(& $gitApp remote)[0] | + ForEach-Object { + & $gitApp remote get-url $_ + } + +# If we could not, throw. +if (-not $repositoryUrl) { + throw "No Repository" +} + +# Make sure this package is related to our repository. +$packageGitRepo = $package.GetRelationship('repository').TargetUri +if ($packageGitRepo -ne $repositoryUrl) { + throw "Package unrelated to '$repositoryUrl'" + return +} + +# Try to get a github event. +$gitHubEvent = if ($env:GITHUB_EVENT_PATH) { + [IO.File]::ReadAllText($env:GITHUB_EVENT_PATH) | ConvertFrom-Json +} else { $null } + +# If there was no event, we are going to prompt. +if (-not $gitHubEvent) { + Write-Warning "No github event found, prompting for confirmation" +} else { + # If there was an event, and it is not merging a pull request, we do not want to potentially tag. + if (-not ($gitHubEvent.head_commit.message -match "Merge Pull Request #(?\d+)") -and + (-not $gitHubEvent.psobject.properties['inputs'])) { + "::warning::Pull Request has not merged, skipping Tagging" | Out-Host + return + } +} + +# Find the existing tags +$existingTags = & $gitApp tag --list +if (-not $existingTags) { + # warn if none exist. + Write-Warning "No tags found" +} + +# Get the target version +$targetVersion = "v$($package.Version)" + +# And find out if it already exists +$versionTagExists = $existingTags -contains $targetVersion + +# If it does +if ($versionTagExists) { + # warn them + "::warning::Version $($versionTagExists)" + return +} + +# If we have an ACTOR and GITHUB_ACTOR_ID +if ($env:GITHUB_ACTOR -AND $env:GITHUB_ACTOR_ID) { + # config git + & $gitApp config --global user.email "$env:GITHUB_ACTOR_ID+$env:GITHUB_ACTOR@users.noreply.github.com" + & $gitApp config --global user.name $env:GITHUB_ACTOR +} + +# Prepare our git tag +$gitTagArgs = @( + 'tag' + '-a' + "v$($package.Version)" + '-m' + "$($package.Identifier) $($package.Version)" +) + +# If -WhatIf was passed, +if ($WhatIfPreference) { + $gitTagArgs # output the arguments to git. + return +} + +# If there was no github event +if (-not $githubEvent) { + # prompt before we create tags + if ($PSCmdlet.ShouldProcess("git tag $gitTagArgs")) { + git @gitTagArgs + } + # and prompt before we push them. + if ($PSCmdlet.ShouldProcess("git push origin --tags")) { + git push origin --tags + } +} else { + # If there was a GitHubEvent + git @gitTagArgs # tag, + git push origin --tags # push tags, + $LASTEXITCODE = 0 # and set the last exit code. +} \ No newline at end of file diff --git a/Types/OpenPackage.Publisher/Markdown.ps1 b/Types/OpenPackage.Publisher/Markdown.ps1 new file mode 100644 index 0000000..9f0e934 --- /dev/null +++ b/Types/OpenPackage.Publisher/Markdown.ps1 @@ -0,0 +1,74 @@ +<# +.SYNOPSIS + Publishes Markdown +.DESCRIPTION + Publishes Markdown within a package as a static site. +.NOTES + This formats all markdown in the package, + and exports each markdown file into an index.html. + + If the markdown file was named README, + will try to place an index in the same directory. +#> +[CmdletBinding(PositionalBinding=$false,SupportsShouldProcess)] +param( +# The destination path of the website. +[Parameter(Position=0)] +[string] +$DestinationPath = './_site', + +# Any input object. This should be one or more packages. +# If no input is provided, will archive the current directory and publish a page. +[Parameter(ValueFromPipeline)] +[Alias('Package')] +[PSObject[]] +$InputObject +) + +# Quickly enumerate all input and arguments. +$allInput = @($input) +if (-not $allInput -and $InputObject) { + $allInput += $InputObject +} + +# If there is no input +if (-not $allInput) { + # exclude our destination from the input + $excludeWildCard = $DestinationPath -replace './', '*' -replace '$', '*' + # and make a package from the current directory. + $allInput = @(Get-OpenPackage -FilePath . -Exclude $excludeWildCard) +} + +# Still no input? Return. +if (-not $allInput) { return } + +# Next up, we can take any markdown in the package and make it an index.html +$allInput | + # First up, format it as markdown. + # This will apply styles and frame the content. + Format-OpenPackage -View Markdown.html | + Foreach-Object { + $md = $_ + $markdownHtmlPath = + if ($md.PartUri -match '/index\.(?>md|markdown)$') { + Join-Path $DestinationPath ( + $md.PartUri -replace '\.(?>md|markdown)$', '.html' + ) + } + elseif ($md.PartUri -match '/README\.(?>md|markdown)$') { + Join-Path $DestinationPath ( + $md.PartUri -replace '/README\.(?>md|markdown)$', '/index.html' + ) + } + elseif ($md.PartUri) { + Join-Path $DestinationPath ( + $md.PartUri -replace '\.(?>md|markdown)$', '/index.html' + ) + } + + if (-not $markdownHtmlPath) { + continue + } + + New-Item -ItemType File -Value "$md" -Path $markdownHtmlPath -Force + } \ No newline at end of file diff --git a/Types/OpenPackage.Publisher/PowerShellGallery.ps1 b/Types/OpenPackage.Publisher/PowerShellGallery.ps1 new file mode 100644 index 0000000..b338398 --- /dev/null +++ b/Types/OpenPackage.Publisher/PowerShellGallery.ps1 @@ -0,0 +1,122 @@ +<# +.SYNOPSIS + Publishes Packages to PowerShell Gallery +.DESCRIPTION + Publishes Packages to PowerShell Gallery, using Publish-Module +.LINK + Publish-Module +#> +[CmdletBinding(PositionalBinding=$false,SupportsShouldProcess,ConfirmImpact='High')] +param( +# One or more packages to publish. +# Any non-package input will be ignored. +[Parameter(ValueFromPipeline)] +[Alias('Package')] +[PSObject[]] +$InputObject, + +# The NuGetApiKey used to publish the packages. +[string] +$NuGetApiKey +) + +# Gather all piped input +$allInput = @($input) + +# If there was none, gather all non-piped input. +if (-not $allInput) { + $allInput += $InputObject +} + +# Get Publish-Module +$publishModuleCommand = $executionContext.SessionState.InvokeCommand.GetCommand('Publish-Module', 'Function,Cmdlet') +if (-not $publishModuleCommand) { + # and throw if it does not exist. + throw "No Publish-Module" + return +} + +# If there is no key, throw. +if (-not $NuGetApiKey) { + throw "No Api Key" +} + +# To publish we need these properties to exist in the manifest +$mustHaves = 'guid', 'description','author','companyname','copyright' + +# Get our github event +$gitHubEvent = if ($env:GITHUB_EVENT_PATH) { + [IO.File]::ReadAllText($env:GITHUB_EVENT_PATH) | ConvertFrom-Json +} else { $null } + +# If there was no github event +if (-not $gitHubEvent) { + # we will prompt + Write-Warning "No github event found, prompting for confirmation" +} else { + # If the github event was not a pull request merge + if (-not ($gitHubEvent.head_commit.message -match "Merge Pull Request #(?\d+)") -and + (-not $gitHubEvent.psobject.properties['inputs'])) { + # skip publishing. + "::warning::Pull Request has not merged, skipping github release" | Out-Host + return + } +} + + +# Go over each input +:nextInput foreach ($in in $allInput) { + # If it it was not a package, continue + if ($in -isnot [IO.Packaging.Package]) { continue } + $package = $in + + # If there is no package identifier, continue. + if (-not $Package.Identifier) { Write-Error "No package identifier"; continue} + + # Check out manifest + $PackageManifest = $package.PowerShellManifest + + # If we do not have a "must-have" + $notHavingIt = $false + foreach ($mustHave in $mustHaves) { + if (-not $PackageManifest.$mustHave) { + # tell them which ones + Write-Error "Package $($package.identifier) has no $mustHave" + $notHavingIt = $true + } + } + + if ($notHavingIt) { + continue + } + + + # If there is no github event, prompt for confirmation + if ((-not $githubEvent) -and (-not $PSCmdlet.ShouldProcess( + "Publish $($package.Identifier) $($package.Version)" + ))) { + continue + } + + + # Publish-Module requires the directory name to be correct + # so we will install into a path + $installPath = "./PowerShellGallery/$($package.Identifier)" + + # Install the module into the install path + $null = $in | + Install-OpenPackage -DestinationPath $installPath -Force -Clear + # Call publish module + & $publishModuleCommand -Path $installPath -NugetApiKey $NuGetApiKey + + # Clean up + Remove-Item -Recurse -Force $installPath + + # If the PowerShellGallery path exists, and is empty + if ((Test-Path ./PowerShellGallery) -and + -not (Get-ChildItem ./PowerShellGallery)) { + # clean it up. + Remove-Item -Path ./PowerShellGallery + } +} + diff --git a/Types/OpenPackage.Publisher/README.md b/Types/OpenPackage.Publisher/README.md new file mode 100644 index 0000000..293ce4f --- /dev/null +++ b/Types/OpenPackage.Publisher/README.md @@ -0,0 +1,35 @@ +# Open Pubishing with Open Packages + +Packages are just a bunch of files. + +On a technical level, publishing is just publishing some files someplace (often in the some way). + +There are many things we can publish, and many places we can publish them. + +`Publish-OpenPackage` is used to publish open packages. + +It accepts a publisher, any arguments, any options, and any input. + +If the publisher is a command or a script block, it will be called directly. + +If the publisher is the name of one of the methods in the type data of `OpenPackage.Publisher`, + +that method will be called. + +Any invalid named parameters will be removed prior to calling the publisher. + + +## OpenPackage.Publisher + +`OpenPackage.Publisher` is contains the built-in publishers. + +More will be added with time. + +### OpenPackage.Publisher.Site + +`OpenPackage.Publisher.Site` publishes packages as a static site. + +This will install a package to a `-DestinationPath` (default `./_site`). + +It will also generate an index.html for each markdown file in the package, if one does not already exist. + diff --git a/Types/OpenPackage.Publisher/Site.ps1 b/Types/OpenPackage.Publisher/Site.ps1 new file mode 100644 index 0000000..668f764 --- /dev/null +++ b/Types/OpenPackage.Publisher/Site.ps1 @@ -0,0 +1,51 @@ +<# +.SYNOPSIS + Publishes a static site +.DESCRIPTION + Publishes a package as a static site. +.NOTES + This installs the package to the -DestinationPath (default `./_site`) +#> +[CmdletBinding(PositionalBinding=$false,SupportsShouldProcess)] +param( +# The destination path of the website. +[Parameter(Position=0)] +[string] +$DestinationPath = './_site', + +# Any input object. This should be one or more packages. +# If no input is provided, will archive the current directory and publish a page. +[Parameter(ValueFromPipeline)] +[Alias('Package')] +[PSObject[]] +$InputObject +) + +# Quickly enumerate all input and arguments. +$allInput = @($input) +if (-not $allInput -and $InputObject) { + $allInput += $InputObject +} + +# If there is no input +if (-not $allInput) { + # exclude our destination from the input + $excludeWildCard = $DestinationPath -replace './', '*' -replace '$', '*' + # and make a package from the current directory. + $allInput = @(Get-OpenPackage -FilePath . -Exclude $excludeWildCard) +} + +# Still no input? Return. +if (-not $allInput) { return } + +# To build a static site out of a package, we just need to install it +$installParameters = [Ordered]@{ + DestinationPath = $DestinationPath + Force = $true + WarningAction = 'SilentlyContinue' + WarningVariable = 'warnings' +} + +# All of the content from that site is the base layer +$allInput | + Install-OpenPackage @installParameters -PassThru -Force \ No newline at end of file diff --git a/Types/OpenPackage.Publisher/at.markpub.markdown.ps1 b/Types/OpenPackage.Publisher/at.markpub.markdown.ps1 new file mode 100644 index 0000000..0afa8fd --- /dev/null +++ b/Types/OpenPackage.Publisher/at.markpub.markdown.ps1 @@ -0,0 +1,62 @@ +<# +.SYNOPSIS + Publishes at.markpub.markdown +.DESCRIPTION + Publishes any markdown content in a package as at.markpub.markdown. + + This will take create a static xrpc endpoint that returns all markdown content within a package. +#> +[CmdletBinding(PositionalBinding=$false)] +param( +# The destination path. By default _site. +[Parameter(Position=0)] +[string] +$DestinationPath = './_site', + +# The namespace identifier. By default `at.markpub.markdown` +[Alias('NSID')] +[ValidatePattern('(?>\.[^\.]+){3,}')] +[string] +$NamespaceIdentifier = 'at.markpub.markdown', + + +# One or more input packages. +# If no packages is provided, will get all markdown files beneath the current directory. +[Parameter(ValueFromPipeline)] +[Alias('Package')] +[PSObject[]] +$InputObject +) + + +$allInput = @($input) +if (-not $allInput -and $InputObject) { + $allInput += $InputObject +} + +if (-not $allInput) { + $excludeWildCard = $DestinationPath -replace '^./', '*' -replace '$', '*' + $allInput = @(Get-OpenPackage -FilePath . -Include *.md, *.markdown -Exclude $excludeWildCard) +} + +if (-not $allInput) { return } + +$allInput = @( + foreach ($in in $allInput) { + if ($in.Package -is [IO.Packaging.Package]) { + $in.Package + } else { + $in + } + } +) + +$openPackageXrpc = $allInput | + Format-OpenPackage -View at.markpub.markdown + +$openPackageXrpcPath = + Join-Path $DestinationPath "./xrpc/$NamespaceIdentifier/index.json" + +New-Item -ItemType File -Path $openPackageXrpcPath -Value ( + $openPackageXrpc | ConvertTo-Json -Depth 10 +) -Force \ No newline at end of file diff --git a/Types/OpenPackage.Publisher/com.atproto.repo.createRecord.ps1 b/Types/OpenPackage.Publisher/com.atproto.repo.createRecord.ps1 new file mode 100644 index 0000000..6dfbabb --- /dev/null +++ b/Types/OpenPackage.Publisher/com.atproto.repo.createRecord.ps1 @@ -0,0 +1,210 @@ +<# +.SYNOPSIS + Publishes Records to At Protocol +.DESCRIPTION + Publishes one or more records to the at protocol. +.LINK + https://github.com/bluesky-social/atproto/blob/main/lexicons/com/atproto/repo/createRecord.json +#> +[CmdletBinding(PositionalBinding=$false,SupportsShouldProcess)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + "PSAvoidUsingPlainTextForPassword", + "", + Justification=" + SecureStrings are not actually more secure. + Use -Credential to avoid potential information disclosure in Windows event logs. + " +)] +param( +# Handle or other identifier supported by the server for the authenticating user. +[string] +$Identifier, + +# The app password or account password. +[string] +$AppPassword, + +# A credential used to connect. +# The username will be treated as the `-Identifier`. +# The password will be treated as the `-AppPassword` +[Management.Automation.PSCredential] +[Alias('PSCredential')] +$Credential, + +# The personal data server used for the connection. +[Alias('PersonalDataServer')] +[string] +$PDS = "https://bsky.social/", + +# The record key. +# This does not need to be provided. +# If no record key is provided, +# records will be created with a TimeStamp Identifier (`tid`) +# See https://atproto.com/specs/tid +# Individual records may also contain a `rkey`. +# If the record includes an `rkey`, this will be used. +[Alias('RecordKey')] +[string] +$RKey, + +# Compare and swap with the previous commit by CID. +[string] +$SwapCommmit, + +# Can be set to 'false' to skip Lexicon schema validation of record data, +# 'true' to require it, or leave unset to validate only for known Lexicons. +[switch] +$Validate, + +# Any input to post. +# Only input with a `$type` property will be posted. +[Parameter(ValueFromPipeline)] +[Alias('Package')] +[PSObject[]] +$InputObject +) + +# Quick collect all piped input +$allInput = @($input) + +# If that had nothing, add any non-piped input objects +if (-not $allInput) { + $allInput += $InputObject +} + +# If we still have no input, +if (-not $allInput) { + # error out. + Write-Error "No input to publish" + return +} + +# Declare a filter to create our records. +filter createRecord { + $NamespaceID = 'com.atproto.repo.createRecord' + $httpMethod = 'POST' + $createAtRecord = $_ + # If the record we want to create has no `$type' + if (-not $createAtRecord.'$type') { + # warn and return + Write-Warning 'No $type, will not createRecord' + return + } + + # Construct our create url + $createUrl = "$( + # If the PDS was `https://`, + if ($pds -like 'https://*') { + # just trim trailing slashes from https urls + $pds -replace '/$' + } else { + # Otherwise prefix it by https:// + "https://$pds" -replace '/$' + })/xrpc/$NamespaceID" + + # Prepare our parameters + $invokeSplat = [Ordered]@{ + Uri = $createUrl + Body = [Ordered]@{} + Method = $httpMethod + ContentType='application/json' + } + + # If there was no connection + if (-not $atConnection) { + Write-Warning "Not connected!" # warn them + return $invokeSplat # and return our parameters. + } + + # If the connection had a did + if ($atConnection.did) { + # This becomes the `repo` + $invokeSplat.Body['repo'] = $atConnection.did + } + + # The collection will always be the record `$type` + $invokeSplat.Body.collection = $createAtRecord.'$type' + + # If we have explicitly provided an rkey + if ($RKey) { + $invokeSplat.Body.rkey = $RKey # use that + } + # Otherwise, if the record explicitly provides an rkey + elseif ($createAtRecord.rkey) { + # use that instead. + $invokeSplat.Body.rkey = $createAtRecord.rkey + } + + # If we want to validate, or the record indicates it should + if ($validate -or $createAtRecord.validate) { + # then we will validate. + $invokeSplat.Body.validate = $true + } + + # If we explicitly provide a swap commit + if ($SwapCommmit) { + # use it. + $invokeSplat.Body.swapCommit = $SwapCommmit + } + # If the record provides a swap commit + elseif ($createAtRecord.swapCommit) { + # use that instead + $invokeSplat.Body.swapCommit = $createAtRecord.swapCommit + } + + # Last but not least, put our record into `.record`. + $invokeSplat.Body.record = $createAtRecord + + # If -WhatIf was passed + if ($WhatIfPreference) { + # return the splat. + return $invokeSplat + } + + # Convert the body to json, + $invokeSplat.Body = $invokeSplat.Body | + ConvertTo-Json -Depth 100 + + # add our bearer token + $invokeSplat.Headers = [Ordered]@{Authorization="Bearer $($atConnection.accessJwt)"} + + # and create the record. + Invoke-RestMethod @invokeSplat +} + + +# Reset any potential connection. +$atConnection = $null +# and then see if we have enough data to connect. +if (-not $atConnection -and ( + ($Identifier -and $appPassword) -or + ($Credential) +)) { + # If we do, prepare a splat + $connectionSplat = [Ordered]@{} + if ($identifier -and $AppPassword) { + $connectionSplat.Identifier = $Identifier + $connectionSplat.AppPassword = $AppPassword + } + else { + $connectionSplat.Credential = $Credential + } + + # and connect. + $atConnection = Publish-OpenPackage -Publisher com.atproto.server.createSession -Option $connectionSplat +} + + +#region Create Records +$InputNumber = 0 +:nextInput foreach ($in in $allInput) { + if ($in.'$type') { + $in | createRecord + } else { + Write-Warning "Input # $InputNumber is missing a '`$type'" + } + $InputNumber++ +} +#endregion Create Records + +$atConnection = $null \ No newline at end of file diff --git a/Types/OpenPackage.Publisher/com.atproto.repo.uploadBlob.ps1 b/Types/OpenPackage.Publisher/com.atproto.repo.uploadBlob.ps1 new file mode 100644 index 0000000..db6e5db --- /dev/null +++ b/Types/OpenPackage.Publisher/com.atproto.repo.uploadBlob.ps1 @@ -0,0 +1,238 @@ +<# +.SYNOPSIS + Publish Blobs to At Protocol +.DESCRIPTION + Publishes content blobs to At Protocol. +.LINK + https://github.com/bluesky-social/atproto/blob/main/lexicons/com/atproto/repo/uploadBlob.json +#> +[CmdletBinding(PositionalBinding=$false,SupportsShouldProcess)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + "PSAvoidUsingPlainTextForPassword", + "", + Justification=" + SecureStrings are not actually more secure. + Use -Credential to avoid potential information disclosure in Windows event logs. + " +)] +param( +# Handle or other identifier supported by the server for the authenticating user. +[string] +$Identifier, + +# The app password or account password. +[string] +$AppPassword, + +# A credential used to connect. +# The username will be treated as the `-Identifier`. +# The password will be treated as the `-AppPassword` +[Management.Automation.PSCredential] +[Alias('PSCredential')] +$Credential, + +# The personal data server used for the connection. +[Alias('PersonalDataServer')] +[string] +$PDS = "https://bsky.social/", + +# A content type map. +# This maps extensions and URIs to a content type. +[Collections.IDictionary] +$TypeMap = $( + ([PSCustomObject]@{PSTypeName='OpenPackage.ContentTypeMap'}).TypeMap +), + +# Any input to publish. +# This can be: +# * A package part +# * A `[IO.FileInfo]` object. +# * A `[Collections.IDictionary]` containing .Content and .ContentType +[Parameter(ValueFromPipeline)] +[Alias('Package')] +[PSObject[]] +$InputObject +) + +# Collect all piped input +$allInput = @($input) + +# If that did not work, collect all non-piped `-InputObject` +if (-not $allInput) { + $allInput += $InputObject +} + +# If there is no input +if (-not $allInput) { + # error out. + Write-Error "No input to publish" + return +} + +# Declare a small function to upload our blobs +function uploadBlob { + param( + # The content to upload. + # Can be bytes, files, or package parts. + [Parameter(Mandatory)] + [PSObject] + $Content, + + # The content type. + [string] + $ContentType + ) + + # Get our content as bytes + [byte[]]$contentBytes = + if ($content -is [byte[]] -or + $content -as [byte[]] + ) { + # If it is already bytes, or castable to bytes + $content # use the content directly + } + # If if is a file + elseif ($content -is [IO.FileInfo]) { + # read all of the file bytes + [IO.File]::ReadAllBytes($content.FullName) + # and attempt to detect the correct content type + if (-not $ContentType) { + $contentType = if ($TypeMap.($content.Extension)) { + $TypeMap.($content.Extension) + } else { + # defaulting to image/jpeg if missing + 'image/jpeg' + } + } + } + # If the content is a stream and we can read it + elseif ( + $content -is [IO.Stream] -and $content.CanRead + ) { + # seek to the start of the stream + $null = $content.Seek('0', 'Begin') + # copy to a memory stream + $memoryStream = [IO.MemoryStream]::new() + $content.CopyTo($memoryStream) + # get the bytes + $memoryStream.ToArray() + # and close up. + $memoryStream.Close() + $memoryStream.Dispose() + } + # If the content is a package part + elseif ($content -is [IO.Packaging.PackagePart]) + { + # use the content type of the package part + if (-not $ContentType) { + $contentType = $content.ContentType + } + # Copy the content to a memory stream + $memoryStream = [IO.MemoryStream]::new() + $contentStream = $content.GetStream('Open', 'Read') + $contentStream.CopyTo($memoryStream) + # get the bytes + $memoryStream.ToArray() + # and close up. + $memoryStream.Close() + $memoryStream.Dispose() + $contentStream.Close() + $contentStream.Dispose() + } + + # If we could not get content as bytes + if (-not $contentBytes) { + # error out + Write-Error "Could not get content as bytes" + return + } + + # Declare the namespace identifier and http method + $NamespaceID = 'com.atproto.repo.uploadBlob' + $httpMethod = 'POST' + # And construct an upload url using our pds. + $uploadUrl = "$( + # If the PDS was already https + if ($pds -like 'https://*') { + # just trim trailing slashes from https urls. + $pds -replace '/$' + } else { + # Otherwise, prefix anything else by https:// + "https://$pds" -replace '/$' + })/xrpc/$NamespaceID" + + # Prepare our invoke parameters + $invokeSplat = [Ordered]@{ + Uri = $uploadUrl + Body = $contentBytes + Method = $httpMethod + ContentType = $ContentType + } + + # If -WhatIf was passed, + if ($WhatIfPreference) { + return $invokeSplat # output our invoke parameters + } + # Otherwise, add the authentication header + $invokeSplat.Headers = [Ordered]@{Authorization="Bearer $($atConnection.accessJwt)"} + # and call the endpoint. + Invoke-RestMethod @invokeSplat +} + +# Reset any potential connection. +$atConnection = $null +# and then see if we have enough data to connect. +if (-not $atConnection -and ( + ($Identifier -and $appPassword) -or + ($Credential) +)) { + # If we do, prepare a splat + $connectionSplat = [Ordered]@{} + if ($identifier -and $AppPassword) { + $connectionSplat.Identifier = $Identifier + $connectionSplat.AppPassword = $AppPassword + } + else { + $connectionSplat.Credential = $Credential + } + + # and connect. + $atConnection = Publish-OpenPackage -Publisher com.atproto.server.createSession -Option $connectionSplat +} + +#region Upload Blobs +$InputNumber = 0 +:nextInput foreach ($in in $allInput) { + if ($in -is [Collections.IDictionary]) { + if (-not ( + ($in.Content -is [byte[]]) -or + ($in.Content -as [byte[]]) -or + ($in.Content -is [IO.Stream] -and $in.Content.CanRead) -or + ($in.Content -is [IO.FileInfo]) -or + ($in.Content -is [IO.Packaging.PackagePart]) + )) { + Write-Warning "Input # $InputNumber must contain Content" + continue + } + if ((-not $in.ContentType) -and ( + $in.Content -isnot [IO.FileInfo] -and + $in.Content -isnot [IO.Packaging.PackagePart] + )) { + Write-Warning "Input # $InputNumber must contain ContentType" + continue + } + $inCopy = [Ordered]@{} + $inCopy.Content = $in.Content + $inCopy.ContentType = $in.ContentType + uploadBlob @inCopy + continue + } + elseif ($in -is [IO.FileInfo] -or + $in -is [IO.Packaging.PackagePart] ) { + uploadBlob -Content $in + continue + } + + $InputNumber++ +} +#endregion Upload Blobs \ No newline at end of file diff --git a/Types/OpenPackage.Publisher/com.atproto.server.createSession.ps1 b/Types/OpenPackage.Publisher/com.atproto.server.createSession.ps1 new file mode 100644 index 0000000..402245c --- /dev/null +++ b/Types/OpenPackage.Publisher/com.atproto.server.createSession.ps1 @@ -0,0 +1,112 @@ +<# +.SYNOPSIS + Creates an at protocol server session +.DESCRIPTION + Creates an at protocol server session. +.NOTES + This can be used by other publishers in order to connect to at protocol. +.LINK + https://github.com/bluesky-social/atproto/blob/main/lexicons/com/atproto/server/createSession.json +#> +[CmdletBinding(PositionalBinding=$false,SupportsShouldProcess)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + "PSAvoidUsingPlainTextForPassword", + "", + Justification=" + SecureStrings are not actually more secure. + Use -Credential to avoid potential information disclosure in Windows event logs. + " +)] +param( +# Handle or other identifier supported by the server for the authenticating user. +[Parameter(Mandatory,ParameterSetName='IdentifierAndPassword')] +[string] +$Identifier, + +# The app password or account password. +[Parameter(Mandatory,ParameterSetName='IdentifierAndAppPassword')] +[string] +$AppPassword, + +# A credential used to connect. +# The username will be treated as the `-Identifier`. +# The password will be treated as the `-AppPassword` +[Parameter(Mandatory,ParameterSetName='Credential')] +[Management.Automation.PSCredential] +[Alias('PSCredential')] +$Credential, + +# The personal data server used for the connection. +[Alias('PersonalDataServer')] +[string] +$PDS = "https://bsky.social/" +) + +# Declare the namespace ID +$NamespaceID = 'com.atproto.server.createSession' +# and the HTTP method used to connect +$httpMethod = 'POST' + +# Create a full url using the PDS and NamespaceId +$authenicationUrl = "$( + # If the pds started with https:// + if ($pds -like 'https://*') { + # just trim trailing slashes from https urls. + $pds -replace '/$' + } else { + # Prefix anything else by https:// + "https://$pds" -replace '/$' +})/xrpc/$NamespaceID" + +# Prepare our parameters to Invoke-RestMethod +$atSplat = [Ordered]@{ + Uri = $authenicationUrl + Method = $httpMethod + ContentType='application/json' + Body = [Ordered]@{} +} + +# Set our identifier and password +$atSplat.body.identifier, $atSplat.body.password = + if ($Identifier -and $AppPassword) { + $Identifier, $AppPassword + } + elseif ($Credential) { + $Credential.UserName + $Credential.GetNetworkCredential().Password + } else { + Write-Warning "Missing authentication details. Provide a -Credential or -Identity and -AppPassword." + return + } + +# and convert our body into json. +$atSplat.body = $atSplat.body | ConvertTo-Json -Depth 4 + +# If `-WhatIf` was passed +if ($WhatIfPreference) { + # remove sensitive information + $atSplat.Remove('Body') + return $atSplat # and return the splat. +} + +# Otherwise, connect. +$authenticated = Invoke-RestMethod @atSplat + +# If the connection does not have an accessJwt, return +if (-not $authenticated.accessJwt) { return } + +# Force `atproto.session` objects to only display the handle and did by default +$updateTypeDataSplat = [Ordered]@{ + Force=$true + TypeName='atproto.session' + DefaultDisplayPropertySet = 'handle','did' +} + +# This prevents sensitive information from being displayed by default +# (like the email or the accessJwt) +Update-TypeData @updateTypeDataSplat + +# Decorate our return data +$authenticated.pstypenames.add('atproto.session') +# and return our authenticated object. +return $authenticated \ No newline at end of file diff --git a/Types/OpenPackage.Publisher/org.poshweb.op.ps1 b/Types/OpenPackage.Publisher/org.poshweb.op.ps1 new file mode 100644 index 0000000..d6a1c6d --- /dev/null +++ b/Types/OpenPackage.Publisher/org.poshweb.op.ps1 @@ -0,0 +1,60 @@ +<# +.SYNOPSIS + Publishes org.poshweb.op +.DESCRIPTION + Publishes a package summary. + + This will take create a static xrpc endpoint that returns a package summary. +#> +[CmdletBinding(PositionalBinding=$false)] +param( +# The destination path. By default _site. +[Parameter(Position=0)] +[string] +$DestinationPath = './_site', + +# The namespace identifier. By default `org.poshweb.op` +[Alias('NSID')] +[ValidatePattern('(?>\.[^\.]+){3,}')] +[string] +$NamespaceIdentifier = 'org.poshweb.op', + + +# One or more input packages. +# If no packages is provided, will get the current directory as a package. +[Parameter(ValueFromPipeline)] +[Alias('Package')] +[PSObject[]] +$InputObject +) + + +$allInput = @($input) +if (-not $allInput -and $InputObject) { + $allInput += $InputObject +} + +if (-not $allInput) { + $excludeWildCard = $DestinationPath -replace './', '*' -replace '$', '*' + $allInput = @(Get-OpenPackage -FilePath . -Exclude $excludeWildCard) +} + +if (-not $allInput) { return } + +$allInput = @(foreach ($in in $allInput) { + if ($in.Package -is [IO.Packaging.Package]) { + $in.Package + } else { + $in + } +}) + +$openPackageXrpc = $allInput | + Format-OpenPackage -View org.poshweb.op + +$openPackageXrpcPath = + Join-Path $DestinationPath "./xrpc/$NamespaceIdentifier/index.json" + +New-Item -ItemType File -Path $openPackageXrpcPath -Value ( + $openPackageXrpc | ConvertTo-Json -Depth 10 +) -Force \ No newline at end of file diff --git a/Types/OpenPackage.Source/At.ps1 b/Types/OpenPackage.Source/At.ps1 new file mode 100644 index 0000000..a739a70 --- /dev/null +++ b/Types/OpenPackage.Source/At.ps1 @@ -0,0 +1,162 @@ +<# +.SYNOPSIS + Gets Open Packages at a location +.DESCRIPTION + Gets Open Packages with `@` syntax. + + Without a domain `@` will be presumed to be github.com + With a domain `@` at will check for records using https and at protocol +#> +param( +# One or more locations, in at syntax. +# `@name` will default to github +# `@domain.name` will default to at protocol and https +[string[]] +$At, + +# A list of file wildcards to include. +[Parameter(ValueFromPipelineByPropertyName)] +[SupportsWildcards()] +[string[]] +$Include, + +# A list of file wildcards to exclude. +[Parameter(ValueFromPipelineByPropertyName)] +[SupportsWildcards()] +[string[]] +$Exclude, + +# The base path within the package. +# Content should be added beneath this base path. +[string] +$BasePath = '/', + +# A content type map. +# This maps extensions and URIs to a content type. +[Collections.IDictionary] +$TypeMap = $( + ([PSCustomObject]@{PSTypeName='OpenPackage.ContentTypeMap'}).TypeMap +), + +# The compression option. +[IO.Packaging.CompressionOption] +[Alias('CompressionLevel')] +$CompressionOption = 'Superfast', + +# If set, will force the redownload of various resources and remove existing files or directories +[switch] +$Force, + +# If set, will include hidden files and folders, except for files beneath `.git` +[Alias('IncludeDotFiles')] +[switch] +$IncludeHidden, + +# If set, will include the `.git` directory contents if found. +[Alias('IncludeGitFile','IncludeGitFiles','IncludeGitDirectory')] +[switch] +$IncludeGit, + +# If set, will include any content found in `/node_modules`. +[Alias('IncludeNodeModules')] +[switch] +$IncludeNodeModule, + +# The current package +[IO.Packaging.Package] +$Package, + +# Any additional headers to pass into a web request. +[Alias('Header')] +[Collections.IDictionary] +$Headers, + +# The number of records to get +[long] +$First, + +# If number of records to skip +[long] +$Skip +) + + +# If a package does not exist +if (-not $package) { + # create one + $memoryStream = [IO.MemoryStream]::new() + $package = [IO.Packaging.Package]::Open($memoryStream, 'OpenOrCreate', 'ReadWrite') + Add-Member -InputObject $package NoteProperty MemoryStream $memoryStream -Force +} + + +# Collect all of our named parameters +$namedParameters = [Ordered]@{} +foreach ($key in $MyInvocation.MyCommand.Parameters.Keys) { + $var = $ExecutionContext.SessionState.PSVariable.Get($key) + if (-not [string]::IsNullOrEmpty($var.value)) { + $namedParameters[$key] = $var.value + } +} + +$namedParameters.Remove('At') +$namedParameters.Remove('Package') + +$outputPackages = @() + +foreach ($atSyntax in $at) { + $parameterCopy = [Ordered]@{} + $namedParameters + if ($atSyntax -notmatch '^@') { + continue + } + # If there is no domain name qualifier, treat it as github.com/ + if ($atSyntax -notmatch '\.') { + $parameterCopy.InputObject = $Package + $parameterCopy.Repository = $atSyntax -replace '^@', 'https://github.com/' + $atRepoPackage = Get-OpenPackage @parameterCopy + foreach ($repoPackage in $atRepoPackage) { + if ( + $repoPackage -is [IO.Packaging.Package] -and + $outputPackages -notcontains $repoPackage + ) { + $outputPackages += $repoPackage + } + } + } + else { + # Otherwise, it could be either at protocol or https. + # Try https first + $parameterCopy.InputObject = $package + $parameterCopy.Url = $atSyntax -replace '^@', 'https://' + + $httpsPackage = Get-OpenPackage @parameterCopy + + if ($httpsPackage) { + $parameterCopy.InputObject = $httpsPackage + } + $parameterCopy.Remove('Url') + $parameterCopy.AtUri = $atSyntax -replace '^@', 'at://' + $atPackage = Get-OpenPackage @parameterCopy + + $possiblePackages = @( + if ($httpsPackage -and ($atPackage -eq $httpsPackage)) { + $httpsPackage + } else { + if ($atPackage) { + $atPackage + } + if ($httpsPackage) { + $httpsPackage + } + } + ) + foreach ($possibility in $possiblePackages ) { + if ($possibility -is [IO.Packaging.Package] -and + $outputPackages -notcontains $possibility) { + $outputPackages += $possibility + } + } + } +} + +return $outputPackages \ No newline at end of file diff --git a/Types/OpenPackage.Source/AtBlob.ps1 b/Types/OpenPackage.Source/AtBlob.ps1 new file mode 100644 index 0000000..2f782a1 --- /dev/null +++ b/Types/OpenPackage.Source/AtBlob.ps1 @@ -0,0 +1,44 @@ +<# +.SYNOPSIS + Gets at blobs +.DESCRIPTION + Gets blobs from the at protocol. +#> +param( +# The decentralized identifier (did) +[Parameter(Mandatory)] +[string] +$did, + +# The content identifier (cid) +[Parameter(Mandatory)] +[string] +$cid, + +# The personal data server (pds) +[string] +$pds = "https://bsky.social/", + +# Any additional headers to pass into a web request. +[Alias('Header')] +[Collections.IDictionary] +$Headers +) + +$invokeSplat = @{ + uri = "$(# Be fault tolerant with the pds format + if ($pds -like 'https://*') { + # just trim trailing slashes from https urls + $pds -replace '/$' + } else { + # and prefix anything else by https:// + "https://$pds" -replace '/$' +})/xrpc/com.atproto.sync.getBlob?did=$did&cid=$cid" +} +if ($headers) { + $invokeSplat.Headers = $Headers +} + +return Invoke-WebRequest @invokeSplat + + diff --git a/Types/OpenPackage.Source/AtProtocol.ps1 b/Types/OpenPackage.Source/AtProtocol.ps1 new file mode 100644 index 0000000..fccc0b5 --- /dev/null +++ b/Types/OpenPackage.Source/AtProtocol.ps1 @@ -0,0 +1,179 @@ +<# +.SYNOPSIS + Gets at protocol data +.DESCRIPTION + Gets data from the at protocol. +.EXAMPLE + Get-OpenPackage at://mrpowershell.com/app.bsky.actor.profile +#> +param( +[Parameter(Mandatory)] +[string[]] +$AtUri, + +# A list of file wildcards to include. +[Parameter(ValueFromPipelineByPropertyName)] +[SupportsWildcards()] +[string[]] +$Include, + +# A list of file wildcards to exclude. +[Parameter(ValueFromPipelineByPropertyName)] +[SupportsWildcards()] +[string[]] +$Exclude, + +# The base path within the package. +# Content should be added beneath this base path. +[string] +$BasePath = '/', + +# A content type map. +# This maps extensions and URIs to a content type. +[Collections.IDictionary] +$TypeMap = $( + ([PSCustomObject]@{PSTypeName='OpenPackage.ContentTypeMap'}).TypeMap +), + +# The compression option. +[IO.Packaging.CompressionOption] +[Alias('CompressionLevel')] +$CompressionOption = 'Superfast', + +# The personal data server. This is used in At Protocol requests. +[string] +$pds = "https://bsky.social", + +# The current package +[IO.Packaging.Package] +$Package, + +# The batch size +[ValidateRange(1,100)] +[int] +$BatchSize = 100, + +# Any additional headers to pass into a web request. +[Alias('Header')] +[Collections.IDictionary] +$Headers, + +# The number of records to get +[long] +$First, + +# If number of records to skip +[long] +$Skip +) + + +if (-not $package) { + $memoryStream = [IO.MemoryStream]::new() + $package = [IO.Packaging.Package]::Open($memoryStream, 'OpenOrCreate', 'ReadWrite') + Add-Member -InputObject $package NoteProperty MemoryStream $memoryStream -Force +} + +if (-not $this) {$this = $package} + +$sources = [PSCustomObject]@{PSTypeName='OpenPackage.Source'} + +filter packAtProtoRecord { + # Declare a package uri for the segment. + # (make sure to switch did colons to something else, so that the files can unpack cleanly regardless of OS) + $currentPackageUri = "/$($atMatch.did -replace ':','_')/$($matches.type)/$($matches.rkey).json" + + # If the part exists, + if ($Package.PartExists($currentPackageUri)) { + $Package.DeletePart($currentPackageUri) # recreate it. + if (-not $?) { return } + } + $atPart = $Package.CreatePart($currentPackageUri, 'application/json', $CompressionOption) + if (-not $atPart) { continue } + # Get the stream. + $atStream = $atPart.GetStream() + if (-not $atStream) { continue } + # Turn our message into json, and get the bytes. + $atJsonBytes = $outputEncoding.GetBytes( + ($atRecord | ConvertTo-Json -Depth 100) + ) + + # Then write them to the stream, + $atStream.Write($atJsonBytes, 0, $atJsonBytes.Count) + + # clean up, + $atStream.Close() + $atStream.Dispose() +} + +foreach ($at in $AtUri) { + # and declare a pattern to pick apart an at uri + $atPattern = 'at://(?[^/]+)/(?[^/]+)(?:/(?.+?$))?' + + # If this does not match the pattern or we don't have a type, we are done. + if ($at -notmatch $atPattern -or + -not $matches.type) { return } + + # Store our match information before anything else needs to `-match`. + $atMatch = [Ordered]@{} + $Matches + # Create a package in memory + + if (-not $package.PackageProperties.Identifier) { + $package.PackageProperties.Identifier = $atMatch.did + } + + # If we have a type and rkey, we are after a single record. + if ($atMatch.type -and $atMatch.rkey) { + $atRecord = $sources.AtRecord($atMatch.did, $atMatch.type, $atMatch.rkey, $pds) + if (-not $atRecord) { continue } + packAtProtoRecord + } + elseif ($atMatch.type -match '\.') { + # If there are dots in the type, it is a collection + foreach ($atRecord in $sources.AtType( + $atMatch.did, $atMatch.type, $BatchSize, $First, $Skip, $pds + )) { + # If the uri is not an at uri + if ($atRecord.uri -notmatch $atPattern) { + # continue to the next record + continue + } + packAtProtoRecord + } + } + else + { + try { + $atBlob = $sources.AtBlob($matches.did, $matches.type) + $atContentType = $atBlob.Headers.'Content-Type' + if (-not $atContentType) { + continue + } + $currentPackageUri = "/$($matches.did -replace ':','_')/$($matches.type).$(@($atContentType -split '[/\+]')[-1])" + $blobPart = $Package.CreatePart($currentPackageUri, $atContentType, $CompressionOption) + $blobStream = $blobPart.GetStream() + $memoryStream = + if ($atBlob.Content -is [byte[]]) { + [IO.MemoryStream]::new($atBlob.Content) + } else { + [IO.MemoryStream]::new([Text.Encoding]::UTF8.GetBytes($atBlob.Content)) + } + $memoryStream.CopyTo($blobStream) + $memoryStream.Close() + $null = $memoryStream.DisposeAsync() + + $blobStream.Close() + $null = $blobStream.DisposeAsync() + + } catch { + Write-Debug "Unable to get $at : $_" + continue + } + } +} + +# Only return a package if it is not empty. +if (@($package.GetParts()).Length) { + return $Package +} + diff --git a/Types/OpenPackage.Source/AtRecord.ps1 b/Types/OpenPackage.Source/AtRecord.ps1 new file mode 100644 index 0000000..83eaea4 --- /dev/null +++ b/Types/OpenPackage.Source/AtRecord.ps1 @@ -0,0 +1,66 @@ +<# +.SYNOPSIS + Gets at records +.DESCRIPTION + Gets records from the at protocol. +.EXAMPLE + Get-OpenPackage at://mrpowershell.com/app.bsky.actor.profile +#> +param( +# Who [did](https://atproto.com/specs/did) (decentralized identifier) +[Parameter(Mandatory)] +[Alias('DectralizedIdentifier')] +[string] +$did, + +# What collection or type +[Parameter(Mandatory)] +[string] +$collection, + +# What is the record key? +[Parameter(Mandatory)] +[string] +$rkey, + +# Where is the data? +[string] +$pds = "https://bsky.social", + +# Any additional headers to pass into a web request. +[Alias('Header')] +[Collections.IDictionary] +$Headers +) + +# Construct the XRPC url to that record +$xrpcUrl = "$( + # Be fault tolerant with the pds format + if ($pds -like 'https://*') { + # just trim trailing slashes from https urls + $pds -replace '/$' + } else { + # and prefix anything else by https:// + "https://$pds" -replace '/$' + } +)/xrpc/com.atproto.repo.getRecord?repo=$( + $did +)&collection=$( + $collection +)&rkey=$( + $Rkey +)" + +# and go fetch. +try { + if ($headers) { + Invoke-RestMethod -Uri $xrpcUrl -Headers $header + } else { + Invoke-RestMethod -Uri $xrpcUrl + } + +} catch { + Write-Verbose "$xrpcUrl - $_" +} + + diff --git a/Types/OpenPackage.Source/AtType.ps1 b/Types/OpenPackage.Source/AtType.ps1 new file mode 100644 index 0000000..5d8df18 --- /dev/null +++ b/Types/OpenPackage.Source/AtType.ps1 @@ -0,0 +1,122 @@ +<# +.SYNOPSIS + Gets at records +.DESCRIPTION + Gets records from the at protocol. +.EXAMPLE + Get-OpenPackage at://mrpowershell.com/app.bsky.actor.profile +#> +param( +# Who [did](https://atproto.com/specs/did) (decentralized identifier) +[Parameter(Mandatory)] +[Alias('DectralizedIdentifier')] +[string] +$did, + +# What collection or type +[Parameter(Mandatory)] +[string] +$collection, + +# How many items to get in each batch. +# By default, 100. +[ValidateRange(1,100)] +[int] +$BatchSize = 100, + +# If provided, will only get N items. +[long] +$First, + +# If provided, will skip N items. +[long] +$Skip, + +# The PDS (Personal Data Server). +[string] +$pds = "https://bsky.social/", + +# Any additional headers to pass into a web request. +[Alias('Header')] +[Collections.IDictionary] +$Headers +) + +$atPattern = 'at://(?[^/]+)/(?[^/]+)(?:/(?.+?$))?' + +$total = [long]0 +$skipped = [long]0 +$cursor = '' +$progress = [Ordered]@{Id = Get-Random} +$progress.Status = "Getting records" +:AtSync do { + $xrpcUrl = "$( + # Be fault tolerant with the pds format + if ($pds -like 'https://*') { + # just trim trailing slashes from https urls + $pds -replace '/$' + } else { + # and prefix anything else by https:// + "https://$pds" -replace '/$' + } + )/xrpc/com.atproto.repo.listRecords?repo=$( + $did + )&collection=$( + $collection + )&limit=$BatchSize&cursor=$Cursor" + $progress.Activity = "$total " + Write-Progress @progress + # Get the page of records + $results = try { + if ($Headers) { + Invoke-RestMethod -Uri $xrpcUrl -Headers $header + } else { + Invoke-RestMethod $xrpcUrl + } + } catch { + $_ + } + if ($results -is [Management.Automation.ErrorRecord]) { + Write-Verbose "$xrpcUrl - $results" + continue + } + # If we got results and have a cursor to more + if ($results -and $results.cursor) { + # set it for the next round. + $Cursor = $results.cursor + } + + # Unroll and store each record. + :nextRecord foreach ($record in $results.records) { + + # Records are sent latest to earliest. + # We can use -Skip to skip N records + if ($Skip -and + $skipped -lt $Skip + ) { + $skipped++ + continue nextRecord + } + + # If the uri is not an at uri + if ($record.uri -notmatch $atPattern) { + # continue to the next record + continue nextRecord + } + + $record + + # Increment our total + $total++ + + # If we provided -First and our total exceeds our -First, break out + if ($First -and + $total -ge $First) { + break AtSync + } + } +} while ($results -and $results.cursor) + +$progress.Remove('PercentComplete') +$progress.Completed = $true +Write-Progress @progress diff --git a/Types/OpenPackage.Source/Dictionary.ps1 b/Types/OpenPackage.Source/Dictionary.ps1 new file mode 100644 index 0000000..8b59491 --- /dev/null +++ b/Types/OpenPackage.Source/Dictionary.ps1 @@ -0,0 +1,130 @@ +<# +.SYNOPSIS + Gets a Dictionary as a Package +.DESCRIPTION + Gets a dictionary as an Open Package. +.NOTES + Keys in the dictionary will treated as file names. + + If the value is a dictionary or list of dictionaries, + the key will become a directory name and its values will be added to the package. +#> + +param( +[Alias('Dictionary')] +[Collections.IDictionary[]] +$DictionaryList, + +# The compression option. +[IO.Packaging.CompressionOption] +[Alias('CompressionLevel')] +$CompressionOption = 'Superfast', + +# A content type map. +# This maps extensions and URIs to a content type. +[Collections.IDictionary] +$TypeMap = $( + ([PSCustomObject]@{PSTypeName='OpenPackage.ContentTypeMap'}).TypeMap +), + +# The base path within the package. +# Content should be added beneath this base path. +[string] +$BasePath = '/', + +# If set, will force the redownload of various resources and remove existing files or directories +[switch] +$Force, + +# The current package. +# If a package is provided, will add content to this package. +# If no package is provided, will create a new package. +[IO.Packaging.Package] +$Package +) + +$BasePath = $BasePath -replace '^/?', '/' -replace '/?$', '/' + +$myself = $MyInvocation.MyCommand.ScriptBlock + +foreach ($dictionary in $dictionaryList) { + if ($dictionary -isnot [Collections.IDictionary]) { continue } + if (-not $package) { + $memoryStream = [IO.MemoryStream]::new() + $package = [IO.Packaging.Package]::Open($memoryStream, 'OpenOrCreate', 'ReadWrite') + $package.PackageProperties.Identifier = $resolvedItem.Name + Add-Member -InputObject $package NoteProperty MemoryStream $memoryStream -Force + } + :nextPart foreach ($key in $dictionary.Keys) { + $value = $dictionary[$key] + $partUri = ($BasePath + $key) -replace '//', '/' + if ($value -is [Collections.IDictionary] -or + $value -as [Collections.IDictionary[]] + ) { + $Package = & $myself -DictionaryList $value -BasePath $partUri -Package $Package + } else { + $newPart = if ($Package.PartExists -and + $Package.PartExists($partUri) + ) { + if (-not $Force) { + Write-Warning "$PartUri already exists, use -Force to overwrite" + continue nextPart + } + $existingPart = $Package.GetPart($partUri) + $partContentType = $existingPart.ContentType + $Package.DeletePart($partUri) + $Package.CreatePart($partUri, $partContentType, $CompressionOption) + } else { + $partContentType = + if ($partUri -match '\.[^\.]+?$' -and $TypeMap -and + $TypeMap[$matches.0]) { + $TypeMap[$matches.0] + } else { + if ($value -as [byte[]]) { + "application/octet-stream" + } else { + "text/plain" + } + } + $Package.CreatePart($partUri, $partContentType, $CompressionOption) + } + + if (-not $newPart) { + continue nextPart + } + + $newStream = $newPart.GetStream() + + if ($value -is [xml]) { + $value.Save($newStream) + } + elseif ($partUri -match '\.json$') { + if ($value -isnot [string] -and $value -notmatch '[^\[\{]') { + $json = $value | ConvertTo-Json -Depth $FormatEnumerationLimit + $buffer = $OutputEncoding.GetBytes("$json") + $newStream.write($buffer, 0, $buffer.Length) + } else { + $value.Save($newStream) + } + } + elseif ($partUri -match '\.clixml$') { + $clixml = [Management.Automation.PSSerializer]::Serialize($value) + $buffer = $OutputEncoding.GetBytes($clixml) + $newStream.write($buffer, 0, $buffer.Length) + } + else { + if ($value -is [byte[]]) { + $newStream.write($value, 0, $value.Length) + } + else { + $buffer = $OutputEncoding.GetBytes("$value") + $newStream.write($buffer, 0, $buffer.Length) + } + } + + $newStream.Close() + $newStream.Dispose() + } + } + $Package +} diff --git a/Types/OpenPackage.Source/Directory.ps1 b/Types/OpenPackage.Source/Directory.ps1 new file mode 100644 index 0000000..3e0ea6c --- /dev/null +++ b/Types/OpenPackage.Source/Directory.ps1 @@ -0,0 +1,303 @@ +<# +.SYNOPSIS + Gets a Directory as a package +.DESCRIPTION + Gets a Directory as an Open Package +#> +param( +# The list of directories +[string[]] +$Directory, + +# A list of file wildcards to include. +[Parameter(ValueFromPipelineByPropertyName)] +[SupportsWildcards()] +[string[]] +$Include, + +# A list of file wildcards to exclude. +[Parameter(ValueFromPipelineByPropertyName)] +[SupportsWildcards()] +[string[]] +$Exclude, + +# The base path within the package. +# Content should be added beneath this base path. +[string] +$BasePath = '/', + +# A content type map. +# This maps extensions and URIs to a content type. +[Collections.IDictionary] +$TypeMap = $( + ([PSCustomObject]@{PSTypeName='OpenPackage.ContentTypeMap'}).TypeMap +), + +# The compression option. +[IO.Packaging.CompressionOption] +[Alias('CompressionLevel')] +$CompressionOption = 'Superfast', + +# If set, will force the redownload of various resources and remove existing files or directories +[switch] +$Force, + +# If set, will include hidden files and folders, except for files beneath `.git` +[Alias('IncludeDotFiles')] +[switch] +$IncludeHidden, + +# If set, will include the `.git` directory contents if found. +# By default, this content will be excluded. +[Alias('IncludeGitFile','IncludeGitFiles','IncludeGitDirectory')] +[switch] +$IncludeGit, + +# If set, will include any content found in `/node_modules`. +# By default, this content will be excluded. +[Alias('IncludeNodeModules')] +[switch] +$IncludeNodeModule, + +# If set, will include any content found in `/_site`. +# By default, this content will be excluded. +[Alias('IncludeWebsite')] +[switch] +$IncludeSite, + +# The current package +[IO.Packaging.Package] +$Package +) + +# Check for a directory +if (-not $Directory) { + # and error out if none is present. + Write-Error "No -Directory" + return +} + +# Get all of the resolved items. +$resolvedItems = Get-Item -Path $Directory + +# Adjust our base path as needed +# (ensure it begins and ends with a slash) +$BasePath = $BasePath -replace '^/?', '/' -replace '/?$', '/' + +# Go over each potential directory +foreach ($resolvedItem in $resolvedItems) { + # If it is not a directory, continue + if ($resolvedItem -isnot [IO.DirectoryInfo]) { + continue + } + + Push-Location -LiteralPath $resolvedItem.FullName + $gciSplat = [Ordered]@{LiteralPath=$resolvedItem.FullName;Recurse=$true;File=$true} + if ($IncludeHidden) { $gciSplat.Force = $true } + $filesToArchive = @(Get-ChildItem @gciSplat) + + # This make take a sec, so let's create a progress bar + $Progress = [Ordered]@{ + Status = " " + Activity = "Creating Package $($resolvedItem.Name)" + Id = Get-Random + } + $total = $filesToArchive.Length + $counter = 0 + + # We will use file types to provide package metadata + + if (-not $package) { + $memoryStream = [IO.MemoryStream]::new() + $package = [IO.Packaging.Package]::Open($memoryStream, 'OpenOrCreate', 'ReadWrite') + if ($resolvedItem.Parent.Name -and + ( + $resolvedItem.Name -as [version] -or + $resolvedItem.Name -as [semver] + ) + ) { + $package.PackageProperties.Identifier = $resolvedItem.Parent.Name + $package.PackageProperties.Version = $resolvedItem.Name + } else { + $package.PackageProperties.Identifier = $resolvedItem.Name + } + + Add-Member -InputObject $package NoteProperty MemoryStream $memoryStream -Force + } + + # Try to get the git app + $gitApp = $ExecutionContext.SessionState.InvokeCommand.GetCommand('git','application') + + # Then see if get can git it. + $canGitIt = $gitApp -and # If git is loaded + (Test-Path ( # and a .git directory exists + Join-Path $resolvedItem.FullName '.git' + )) + + # If we can git it. + if ($canGitIt) { + # get it's first remote + $gitRemote = @(& $gitApp '-C' $resolvedItem.FullName remote)[0] | + ForEach-Object { + & $gitApp '-C' $resolvedItem.FullName remote get-url $_ + } + + # and create a relationship to the repository + $relation = $package.Relate($gitRemote,'git','repository') + if ($VerbosePreference -notin 'silentlyContinue', 'ignore') { + Write-Verbose "Related $( + $relation.TargetUri + ) as [$($relation.RelationshipType)]$($relation.id)" + } + } + + # So declare an oldest created file and newest write time. + $oldestCreationTime = [DateTime]::Now + $lastWriteTime = [DateTime]::MinValue + + #region Filter Filters + $filteredFiles = @( + # If any exclusions are present, + # security dictates we process them first. + # (deny before approve) + :filterFiles foreach ($file in $filesToArchive) { + $relativePath = $file.FullName.Substring($resolvedItem.FullName.Length) + # If we have not excplitly included `.git`, + if (-not $includeGit -and $relativePath -match '[\\/].git[\\/]') { + continue # exclude `.git`. + } + # If we have not explicitly included `node_modules` + if (-not $IncludeNodeModule -and $relativePath -match '[\\/]node_modules[\\/]') { + continue # exclude `node_modules`. + } + # If we have not explicitly included `_site` + if (-not $IncludeSite -and $relativePath -match '[\\/]_site[\\/]') { + continue # exclude `_site`. + } + + # If we have any exclusion wildcards + if ($exclude) { + foreach ($exclusion in $Exclude) { + if ($file.FullName -like $exclusion) { + continue filterFiles # exclude any files that match + } + } + } + + # If we have any include wildcards + if ($include) { + $included = $false + foreach ($inclusion in $include) { + if ($file.FullName -like $inclusion) { + $included = $true # only include things that match. + break + } + } + + if (-not $included) { continue filterFiles } + } + + $file + } + ) + #region Filter Filters + + # Go over each file we want to archive + :packingFiles foreach ($file in $filteredFiles) { + # get each file as a relative uri, and then get it's bytes + $relativeUri = ( + $BasePath, ( + $file.FullName.Substring($resolvedItem.FullName.Length) -replace '[\\/]', '/' + ) -join '/' + ) -replace '^/?', '/' -replace '//{2,}', '/' + + $fileBytes = Get-Content -AsByteStream -Raw -LiteralPath $file.FullName + + # If the file was blank + if (-not $fileBytes) { + # write a message to verbose indicating we are skipping the file. + Write-Verbose "Skipping blank file $($file.FullName)" + continue + } + + # encode our URI, + $encodedUri = [IO.Packaging.PackUriHelper]::CreatePartUri($relativeUri) + + # make it a root relative uri. + $relativeUri = '/' + ($encodedUri -replace '^/') + + # Determine the right content type for the extension + $fileContentType = $typeMap[$file.Extension] + # and fall back to text/plain + if (-not $fileContentType) { $fileContentType = 'text/plain'} + + # Then update our creation times / last write times as needed. + if ($file.CreationTime -lt $oldestCreationTime) { + $oldestCreationTime = $file.CreationTime + } + if ($file.LastWriteTime -gt $lastWriteTime) { + $lastWriteTime = $file.LastWriteTime + } + + # Write our progress message + $progress.PercentComplete = (++$counter * 100 / $total) + $Progress.Status = "$relativeUri" + Write-Progress @Progress + + if (-not $fileBytes) { continue } + + # Try to create a new part + try { + $newPart = $package.CreatePart($relativeUri, $fileContentType, $CompressionOption) + } catch { + # If that didn't work, + $ex = $_ + # at least one exception has some well known answers + if ($ex.Exception.HResult -eq 0x80131501) { + # Open Packaging Conventions do not allow case-sensitive collisions + # (most likely because they would not extract well on all operating systems) + # If we find an exception indicating a conflict, and multiple copies + $multipleCopies = @($filesToArchive | + Where-Object Fullname -ieq $file.FullName | + Select-Object -ExpandProperty Fullname) + if ($multipleCopies.Count) { + # warn the user + Write-Warning "Skipping '$($File.Fullname)' - Case Sensitivity conflict between:$( + @( + [Environment]::NewLine + # and provide a helpful pair of paths so they can resolve the conflict + $multipleCopies + ) -join + [Environment]::NewLine + ) $ex" + } else { + # If there were not multiple files, error (but do not return) + Write-Error -ErrorRecord $ex + } + } else { + # and if the error was unknown, error (but do not return) + Write-Error -ErrorRecord $ex + } + } + + # If we could not create the part, continue + if (-not $newPart) { continue } + + $newStream = $newPart.GetStream() + $newStream.Write($fileBytes, 0, $fileBytes.Length) + $newStream.Close() + $null = $newStream.DisposeAsync() + } + + Pop-Location + + $Progress.Remove('PercentComplete') + $Progress.Completed = $true + Write-Progress @Progress + $package.PackageProperties.Created = $oldestCreationTime + $package.PackageProperties.Modified = $lastWriteTime + $package +} + + + \ No newline at end of file diff --git a/Types/OpenPackage.Source/Node.ps1 b/Types/OpenPackage.Source/Node.ps1 new file mode 100644 index 0000000..43fcc96 --- /dev/null +++ b/Types/OpenPackage.Source/Node.ps1 @@ -0,0 +1,189 @@ +<# +.SYNOPSIS + Gets a node package +.DESCRIPTION + Gets a node package as an open package +#> +param( +# A node package. +# This can be the name of the package in npm or a package link from `npmjs.com` or `npmx.dev` +[Parameter(ValueFromPipelineByPropertyName)] +[Alias('nodepackages', 'nodepack')] +[string[]] +$NodePackage, + +# A list of file wildcards to include. +[Parameter(ValueFromPipelineByPropertyName)] +[SupportsWildcards()] +[string[]] +$Include, + +# A list of file wildcards to exclude. +[Parameter(ValueFromPipelineByPropertyName)] +[SupportsWildcards()] +[string[]] +$Exclude, + +# The base path within the package. +# Content should be added beneath this base path. +[string] +$BasePath = '/', + +# A content type map. +# This maps extensions and URIs to a content type. +[Collections.IDictionary] +$TypeMap = $( + ([PSCustomObject]@{PSTypeName='OpenPackage.ContentTypeMap'}).TypeMap +), + +# The compression option. +[IO.Packaging.CompressionOption] +[Alias('CompressionLevel')] +$CompressionOption = 'Superfast', + +# If set, will force the redownload of various resources and remove existing files or directories +[switch] +$Force, + +# If set, will include hidden files and folders, except for files beneath `.git` +[Alias('IncludeDotFiles')] +[switch] +$IncludeHidden, + +# If set, will include the `.git` directory contents if found. +# By default, this content will be excluded. +[Alias('IncludeGitFile','IncludeGitFiles','IncludeGitDirectory')] +[switch] +$IncludeGit, + +# If set, will include any content found in `/node_modules`. +# By default, this content will be excluded. +[Alias('IncludeNodeModules')] +[switch] +$IncludeNodeModule, + +# If set, will include any content found in `/_site`. +# By default, this content will be excluded. +[Alias('IncludeWebsite')] +[switch] +$IncludeSite, + +[string[]] +$NodeRepositoryDomain = @( + 'npmjs.com' + 'npmx.dev' + 'www.npmjs.com' + 'www.npmx.dev' +) +) + +$npmApp = $ExecutionContext.SessionState.InvokeCommand.GetCommand('npm', 'Application') +if (-not $npmApp) { throw "npm not installed or in path"} + +# Collect all named parameters to this, +# so we can pass most of them to the next step. +$namedParameters = [Ordered]@{} + +foreach ($key in $MyInvocation.MyCommand.Parameters.Keys) { + $var = $ExecutionContext.SessionState.PSVariable.Get($key) + if (-not [string]::IsNullOrEmpty($var.value)) { + $namedParameters[$key] = $var.value + } +} + +# Remove any node package specific parameters. +$namedParameters.Remove('NodePackage') +$namedParameters.Remove('NodeRepositoryDomain') + +# We want to +if (-not $env:OpenPackagePath) {throw "No Open Package Path"} + +# Get our first OpenPackage package path +$myAppData = @($env:OpenPackagePath -split $( + if ($IsLinux -or $IsMacOS) { + ':' + } else { + ';' + } +))[0] + +# and put all npm packages beneathh this location. +$nodePackRoot = Join-Path $myAppData "node_packages" + +# If we do not have this directory already, create it +if (-not (Test-Path $nodePackRoot)) { + $null = New-Item -ItemType Directory -Path $nodePackRoot -Force +} + +# Push into this location +Push-Location $nodePackRoot + +# Go over each package we want to get. +foreach ($nodePack in $nodePackage) { + # Packages can be in direct named form, or in a url + $nodePackUri = $nodePack -as [uri] + # If the url was absolute + if ($nodePackUri.IsAbsoluteUri) { + # check it against possible domain names. + if ($nodePackUri.DnsSafeHost -notin $NodeRepositoryDomain) { + Write-Error "Can only use an absolute url from $NodeRepositoryDomain" + continue + } + # If the url was versioned + if ($nodePackUri.Segments -match 'v/') { + # split it into two parts + $beforeV, $afterV = $nodePackUri.Segments -join '' -split 'v/' + # and put things in the format npm wants. + $nodePack = "$($beforeV -replace '^/' -replace '^/package' -replace '/$')@$afterV" + } else { + # Otherwise, split off the package segment of the url + $nodePack = + $nodePackUri.Segments[0..3] -ne '/' -join + '' -replace '^package/' -replace '/$' + } + } + # `npm pack` will download a package as .tar.gz + # (with a number of messages on standard error) + $npmOutput = @(& $npmApp pack $nodePack *>&1) + # some of these may actually be errors, so create a collection. + $npmErrors = @() + # and make sure we track if the package has been downloaded + # (for it will actually provide the name twice) + $packageDownloaded = $false + # Go over each output. + :gotPackage foreach ($npmOut in $npmOutput) { + # If any of the output is an error record + if ($npmOut -is [Management.Automation.ErrorRecord]) { + # add it to our list + $npmErrors += $npmOut + } + # If the output is not .tgz + if ($npmOut -notmatch '\S+\.tgz$') { + continue # continue + } + # Get the file path from our match + $npmTarFile = $matches.0 + # If the file does not exist + if (-not (Test-Path $npmTarFile)) { + # output our errors + $npmErrors + } else { + # Otherwise, get the tar file as an open package. + $nodePackage = Get-OpenPackage -FilePath "./$npmTarFile" @namedParameters + # If the output was a package + if ($nodePackage -is [IO.Packaging.Package]) { + # mark that we've downloaded it, + $packageDownloaded = $true + $nodePackage # emit the package, + break gotPackage # and take a break. + } + } + } + # If the packge was not downloaded, output errors. + if (-not $packageDownloaded) { + $npmErrors + } +} +Pop-Location + + diff --git a/Types/OpenPackage.Source/Nuget.ps1 b/Types/OpenPackage.Source/Nuget.ps1 new file mode 100644 index 0000000..5048d47 --- /dev/null +++ b/Types/OpenPackage.Source/Nuget.ps1 @@ -0,0 +1,49 @@ +<# +.SYNOPSIS + Gets a Nuget package +.DESCRIPTION + Gets a Nuget package, given a url. + + Nuget packages are already Open Packages +.EXAMPLE + Get-OpenPackage +#> +param( +[Parameter(Mandatory)] +[uri]$Nuget, + +# Any additional headers to pass into a web request. +[Alias('Header')] +[Collections.IDictionary] +$Headers +) + +if ($nuget.Segments.Count -lt 2) { + throw "Not enough information in $nuget" +} +if ($nuget.Segments[1] -notmatch 'api') { + $nuSegments = $NuGet.Segments -replace 'packages', 'api/v2/package' -join '' + $nuget = $nuget.Scheme + '://' + $NuGet.DnsSafeHost + $( + if ($NuGet.Port -notin 80, 443) { + ":$($NuGet.Port)" + } + ) + $nuSegments +} + +if ($nuget.Segments[1] -match 'api') { + if ($headers) { + $downloadPackage = Invoke-WebRequest -Uri $nuget -Headers $Headers + } else { + $downloadPackage = Invoke-WebRequest -Uri $nuget + } + + if ($downloadPackage.Content -is [byte[]]) { + $memoryStream = [IO.MemoryStream]::new($downloadPackage.Content) + $nugetPackage = [IO.Packaging.Package]::Open($memoryStream, 'OpenOrCreate', 'ReadWrite') + $nugetPackage | Add-Member NoteProperty MemoryStream $memoryStream -Force + $nugetPackage + } +} else { + throw "Could not convert $($NuGet) to a package URL" + return +} \ No newline at end of file diff --git a/Types/OpenPackage.Source/Python.ps1 b/Types/OpenPackage.Source/Python.ps1 new file mode 100644 index 0000000..7504478 --- /dev/null +++ b/Types/OpenPackage.Source/Python.ps1 @@ -0,0 +1,184 @@ +<# +.SYNOPSIS + Gets a python package +.DESCRIPTION + Gets a python package as an open package +#> +param( +# A python package. +# This can be the name of the package in PyPi or a package link from `PyPi.org` +[Parameter(ValueFromPipelineByPropertyName)] +[Alias('PyPi', 'Pip')] +[string[]] +$PythonPackage, + +# A list of file wildcards to include. +[Parameter(ValueFromPipelineByPropertyName)] +[SupportsWildcards()] +[string[]] +$Include, + +# A list of file wildcards to exclude. +[Parameter(ValueFromPipelineByPropertyName)] +[SupportsWildcards()] +[string[]] +$Exclude, + +# The base path within the package. +# Content should be added beneath this base path. +[string] +$BasePath = '/', + +# A content type map. +# This maps extensions and URIs to a content type. +[Collections.IDictionary] +$TypeMap = $( + ([PSCustomObject]@{PSTypeName='OpenPackage.ContentTypeMap'}).TypeMap +), + +# The compression option. +[IO.Packaging.CompressionOption] +[Alias('CompressionLevel')] +$CompressionOption = 'Superfast', + +# If set, will force the redownload of various resources and remove existing files or directories +[switch] +$Force, + +# If set, will include hidden files and folders, except for files beneath `.git` +[Alias('IncludeDotFiles')] +[switch] +$IncludeHidden, + +# If set, will include the `.git` directory contents if found. +# By default, this content will be excluded. +[Alias('IncludeGitFile','IncludeGitFiles','IncludeGitDirectory')] +[switch] +$IncludeGit, + +# If set, will include any content found in `/node_modules`. +# By default, this content will be excluded. +[Alias('IncludeNodeModules')] +[switch] +$IncludeNodeModule, + +# If set, will include any content found in `/_site`. +# By default, this content will be excluded. +[Alias('IncludeWebsite')] +[switch] +$IncludeSite, + +[string[]] +$PythonPackageIndexDomain = @( + 'pypi.org' + 'www.pypi.org' +) +) + +$pipApp = $ExecutionContext.SessionState.InvokeCommand.GetCommand('pip', 'Application') +if (-not $pipApp) { throw "pip not installed or in path"} + +# Collect all named parameters to this, +# so we can pass most of them to the next step. +$namedParameters = [Ordered]@{} + +foreach ($key in $MyInvocation.MyCommand.Parameters.Keys) { + $var = $ExecutionContext.SessionState.PSVariable.Get($key) + if (-not [string]::IsNullOrEmpty($var.value)) { + $namedParameters[$key] = $var.value + } +} + +# Remove any node package specific parameters. +$namedParameters.Remove('PythonPackage') +$namedParameters.Remove('PythonPackageIndexDomain') + +# We want to +if (-not $env:OpenPackagePath) {throw "No Open Package Path"} + +# Get our first OpenPackage package path +$myAppData = @($env:OpenPackagePath -split $( + if ($IsLinux -or $IsMacOS) { + ':' + } else { + ';' + } +))[0] + +# and put all python packages beneathh this location. +$pythonPackageRoot = Join-Path $myAppData "python" + +# If we do not have this directory already, create it +if (-not (Test-Path $pythonPackageRoot)) { + $null = New-Item -ItemType Directory -Path $pythonPackageRoot -Force +} + +# Push into this location +Push-Location $pythonPackageRoot + +# Go over each package we want to get. +foreach ($pythonPack in $PythonPackage) { + # Packages can be in direct named form, or in a url + $pythonPackUri = $pythonPack -as [uri] + # If the url was absolute + if ($pythonPackUri.IsAbsoluteUri) { + # check it against possible domain names. + if ($pythonPackUri.DnsSafeHost -notin $PythonPackageIndexDomain) { + Write-Error "Can only use an absolute url from $PythonPackageIndexDomain" + continue + } + # If the url was versioned + if ($pythonPackUri.Segments[-1] -match '^\d+\.') { + $pythonPack = $pythonPackUri.Segments[-2,-1] -replace '/' -join '==' + } else { + # Otherwise, split off the package segment of the url + $pythonPack = + $pythonPackUri.Segments[2] -replace '/' + } + } + # `pip` will download a package as .whl + # (with a number of messages on standard error) + $pipOutput = @(& $pipApp download '-d' $pythonPackageRoot $pythonPack *>&1) + # some of these may actually be errors, so create a collection. + $pipErrors = @() + # and make sure we track if the package has been downloaded + # (for it will actually provide the name twice) + $packageDownloaded = $false + # Go over each output. + :gotPackage foreach ($pipOut in $pipOutput) { + # If any of the output is an error record + if ($pipOut -is [Management.Automation.ErrorRecord]) { + # add it to our list + $pipErrors += $pipOut + } + # If the output is not .whl + if ($pipOut -notmatch '\S+\.whl$') { + continue # continue + } + # Get the file path from our match + $whlFile = $matches.0 + # If the file does not exist + if (-not (Test-Path $whlFile)) { + # output our errors + $pipErrors + } else { + # Otherwise, get the whl file as an open package. + $pipPackage = Get-OpenPackage -FilePath $whlFile @namedParameters + # If the output was a package + if ($pipPackage -is [IO.Packaging.Package]) { + # mark that we've downloaded it, + $packageDownloaded = $true + $pipPackage # emit the package, + break gotPackage # and take a break. + } + } + } + # If the packge was not downloaded, output errors. + if (-not $packageDownloaded) { + Write-Error $( + $pipErrors -replace '^ERROR:\s' -join [Environment]::NewLine + ) + continue + } +} +Pop-Location \ No newline at end of file diff --git a/Types/OpenPackage.Source/README.md b/Types/OpenPackage.Source/README.md new file mode 100644 index 0000000..94061c0 --- /dev/null +++ b/Types/OpenPackage.Source/README.md @@ -0,0 +1,116 @@ +# Open Package Sources + +Anything can become a package. + +However, different things become packages in different ways. + +`OpenPackage.Source` describes the supported open package sources. + +You should be able to use any `OpenPackage.Source` script to get packages, or data that can be put into a package. + +Current sources: + +## OpenPackage.Source + +OpenPackage.Source is the pseudo type that represents sources. + +Each ScriptMethod in the type can be used directly, and called from it's file, located in `./Types/OpenPackage.Source` + +`OpenPackage.Source` methods are most commonly called thru Get-OpenPackage (it is getting packages from a source) + +OpenPackage sources may call Get-OpenPackage recursively. + +If they do, they should include any parameters to `Get-OpenPackage` that are in all parameter sets. + +### At + +At enables `@` syntax, and supports getting content either from At Protocol or from any domain. + +For example: + +~~~PowerShell +op @( + '@MrPowerShell.com/site.standard.document' + '@MrPowerShell.com/site.standard.publication' + '@MrPowerShell.com/MrPowerShell.png' + '@MrPowerShell.com/MrPowerShell.svg' +) +~~~ + +This will: + +* Get [standard site documents](https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=mrpowershell.com&collection=site.standard.document&limit=100) from at protocol +* Get [standard site publications](https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=mrpowershell.com&collection=site.standard.publication&limit=100) from at protocol * Get [a png logo](https://MrPowerShell.com/MrPowerShell.png) from [MrPowerShell.com](https://MrPowerShell.com) +* Get [a svg logo](https://MrPowerShell.com/MrPowerShell.svg) from [MrPowerShell.com](https://MrPowerShell.com) + +If no domain is specified, at syntax will default to looking for a user or organization on GitHub.com. + +### AtBlob + +AtBlob is used to retreive public blobs from At Protocol. It does not store the blob in a package. + +### AtProtocol + +`AtProtocol` creates packages from At Protocol. It calls either AtBlob, AtRecord, or AtType and stores them in a package. + +### AtRecord + +`AtRecord` gets a single At Protocol record. It does not store the record in a package. + +### AtType + +`AtType` gets at records of a given type. It does not store the records in a package. + +### Dictionary + +`Dictionary` creates a package from a dictionary. + +Each key is a file or directory name. + +Each value can be file content or a dictionary. + +## Directory + +`Directory` creates a package from a directory + +Each file will be copied into the package. + +## Nuget + +`Nuget` gets a package from a Nuget repository. + +Since Nuget packages are already open packages, this directly returns the content. + +## Repository + +`Repository` gets a package from a git repository. + +This will attempt to clone this repository, and supports sparse filtering of the repository contents. + +Once the repository is cloned, it will be read in as a `Directory`. + +## Tar + +`tar` gets a package from a `.tar` or `.tar.gz` file. + +This requires either the tar application or the `System.Formats.Tar` assembly. + +## Url + +`url` gets a package from one or more urls. + +Any url is acceptable. + +Some urls may be processed by another source. + +For example: + +* Github urls will be processed with `Repository` +* `at://` urls will be processed with `AtProtocol` +* [Nuget](https://nuget.org/), [PowerShell Gallery](https://PowerShellGallery.com), and [Chocolatey](https://community.chocolatey.org/) packages will processed with `nuget` + +## Zip + +`zip` gets packages from a zip file. + +If the file is not already an open package, extracts the zip and creates an open package from the zip file. \ No newline at end of file diff --git a/Types/OpenPackage.Source/Repository.ps1 b/Types/OpenPackage.Source/Repository.ps1 new file mode 100644 index 0000000..3fce96b --- /dev/null +++ b/Types/OpenPackage.Source/Repository.ps1 @@ -0,0 +1,260 @@ +<# +.SYNOPSIS + Gets a repository as a package +.DESCRIPTION + Gets a repository as an open package +#> +param( +# A Repository to package. +# This can be the root of a repo or a link to a portion of the tree. +# If a portion of the tree is provided, will perform a sparse clone of the repository +[Parameter(ValueFromPipelineByPropertyName)] +[Alias('clone_url')] +[string] +$Repository, + +# The github branch name. +[Parameter(ValueFromPipelineByPropertyName)] +[string] +$Branch, + +# One or more optional sparse filters to a repository. +# If these are provided, only files matching these filters will be downloaded. +[Parameter(ValueFromPipelineByPropertyName)] +[string[]] +$SparseFilter, + + +# A list of file wildcards to include. +[Parameter(ValueFromPipelineByPropertyName)] +[SupportsWildcards()] +[string[]] +$Include, + +# A list of file wildcards to exclude. +[Parameter(ValueFromPipelineByPropertyName)] +[SupportsWildcards()] +[string[]] +$Exclude, + +# The base path within the package. +# Content should be added beneath this base path. +[string] +$BasePath = '/', + +# A content type map. +# This maps extensions and URIs to a content type. +[Collections.IDictionary] +$TypeMap = $( + ([PSCustomObject]@{PSTypeName='OpenPackage.ContentTypeMap'}).TypeMap +), + +# The compression option. +[IO.Packaging.CompressionOption] +[Alias('CompressionLevel')] +$CompressionOption = 'Superfast', + +# If set, will force the redownload of various resources and remove existing files or directories +[switch] +$Force, + +# If set, will include hidden files and folders, except for files beneath `.git` +[Alias('IncludeDotFiles')] +[switch] +$IncludeHidden, + +# If set, will include the `.git` directory contents if found. +# By default, this content will be excluded. +[Alias('IncludeGitFile','IncludeGitFiles','IncludeGitDirectory')] +[switch] +$IncludeGit, + +# If set, will include any content found in `/node_modules`. +# By default, this content will be excluded. +[Alias('IncludeNodeModules')] +[switch] +$IncludeNodeModule, + +# If set, will include any content found in `/_site`. +# By default, this content will be excluded. +[Alias('IncludeWebsite')] +[switch] +$IncludeSite +) + +if (-not $repository) { + throw "No repository" +} + +$gitApp = $ExecutionContext.SessionState.InvokeCommand.GetCommand('git', 'Application') +if (-not $gitApp) { + throw "No git" +} + +if (-not $env:OpenPackagePath) { + throw "No Open Package Path" +} + + + +$myAppData = $env:OpenPackagePath -split $( + if ($IsLinux -or $IsMacOS) { + ':' + } else { + ';' + } +) + +$namedParameters = [Ordered]@{} + +foreach ($key in $MyInvocation.MyCommand.Parameters.Keys) { + $var = $ExecutionContext.SessionState.PSVariable.Get($key) + if (-not [string]::IsNullOrEmpty($var.value)) { + $namedParameters[$key] = $var.value + } +} + +$namedParameters.Remove('Repository') +$namedParameters.Remove('Branch') +$namedParameters.Remove('SparseFilter') + +$repositoryUrl = $Repository + +# See if we are matching a repo or a tree or blob. +$treePattern = '/(?>tree|blob)/(?[^/]+)/' +if ($Repository -match $treePattern -and -not $NamedParameters.SparseFilter) { + # If we are matching a tree, turn the file portion into a sparse filter + $branch = $matches.branch + $NamedParameters.SparseFilter = + $SparseFilter = + $RepositoryUrl -replace "^.+?$treePattern" -replace '^/?', '/' -replace '/?$', '/' -replace '(?&1 | + . $writeGitProgress + # and push into the location + Push-Location -LiteralPath $repoDirectory + # then set our sparse filter + $SparseArgs = @($SparseFilter) + & $gitApp sparse-checkout set --no-cone @SparseArgs *>&1 | + . $writeGitProgress + # and checkout. + & $gitApp checkout @checkoutBranch *>&1 | + . $writeGitProgress + # we should now have the files, so prepare an -Include filter + if (-not $namedParameters.Include) { + $namedParameters.Include = foreach ($sparse in $SparseFilter) { + "*$sparse" + } + } + # clean up our progress + $gitProgress.Completed = $true + Write-Progress @gitProgress + # and call ourselves with the repoDirectory + Get-OpenPackage -FilePath $pwd @namedParameters + Pop-Location + } else { + # If no sparse filters were provided, just clone + & $gitApp clone $Repository *>&1 | + . $writeGitProgress + + $gitProgress.Completed = $true + Write-Progress @gitProgress + + if ($?) { + Push-Location $repoDirectory + # and call ourself + Get-OpenPackage -FilePath $pwd @namedParameters + Pop-Location + } + } + + Pop-Location + if (-not $?) { return } +} +else { + # If the directory already exists, go there + Push-Location $repoDirectory + # and checkout + & $gitApp checkout @checkoutBranch | . $writeGitProgress + # and if a sparse filter was provided + if ($SparseFilter) { + if (-not $namedParameters.Include) { + # try to only include those files + $namedParameters.Include = foreach ($sparse in $SparseFilter) { + "*$sparse" + } + } + } + $gitProgress.Completed = $true + Write-Progress @gitProgress + + # Call get-openpackage and make a package from this directory + Get-OpenPackage -FilePath $repoDirectory @namedParameters + Pop-Location +} \ No newline at end of file diff --git a/Types/OpenPackage.Source/Tar.ps1 b/Types/OpenPackage.Source/Tar.ps1 new file mode 100644 index 0000000..eb3329c --- /dev/null +++ b/Types/OpenPackage.Source/Tar.ps1 @@ -0,0 +1,162 @@ +<# +.SYNOPSIS + Gets a tarfile as a package +.DESCRIPTION + Gets a tarfile as an open package. +#> +param( +# The path to a tarfile. +[Parameter(Mandatory,ValueFromPipelineByPropertyName)] +[string] +$TarFile, + +# A list of file wildcards to include. +[Parameter(ValueFromPipelineByPropertyName)] +[SupportsWildcards()] +[string[]] +$Include, + +# A list of file wildcards to exclude. +[Parameter(ValueFromPipelineByPropertyName)] +[SupportsWildcards()] +[string[]] +$Exclude, + +# The base path within the package. +# Content should be added beneath this base path. +[string] +$BasePath = '/', + +# A content type map. +# This maps extensions and URIs to a content type. +[Collections.IDictionary] +$TypeMap = $( + ([PSCustomObject]@{PSTypeName='OpenPackage.ContentTypeMap'}).TypeMap +), + +# The compression option. +[IO.Packaging.CompressionOption] +[Alias('CompressionLevel')] +$CompressionOption = 'Superfast', + +# If set, will force the redownload of various resources and remove existing files or directories +[switch] +$Force, + +# If set, will include hidden files and folders, except for files beneath `.git` +[Alias('IncludeDotFiles')] +[switch] +$IncludeHidden, + +# If set, will include the `.git` directory contents if found. +[switch] +$IncludeGit, + +# If set, will include any content found in `/node_modules`. +[Alias('IncludeNodeModules')] +[switch] +$IncludeNodeModule +) + +$namedParameters = [Ordered]@{} +foreach ($key in $MyInvocation.MyCommand.Parameters.Keys) { + $var = $ExecutionContext.SessionState.PSVariable.Get($key) + if ($var) { + $namedParameters[$key] = $var.value + } +} +$namedParameters.Remove('TarFile') + +# Put tar files into their own subdirectory +foreach ($resolvedItem in Get-Item -Path $TarFile) { + # First lets peek at the magic bytes that might give us a clue + $peekMagicBytes = Get-Content -AsByteStream -LiteralPath $resolvedItem.FullName -First 5 + $myAppData = if ($env:OpenPackagePath) { + # Get our first OpenPackage package path + @($env:OpenPackagePath -split $( + if ($IsLinux -or $IsMacOS) { + ':' + } else { + ';' + } + ))[0] + } else { + $pwd + } + $tarDestination = Join-Path $myAppData 'tar' + if (-not (Test-Path $tarDestination)) { + $newDir = New-Item -ItemType Directory -Path $tarDestination -Force + if (-not $newDir) { return } + } + + # and each destination in its own subdirectory + $thisTarDestination = Join-Path $tarDestination ($resolvedItem.Name -replace '\.gz$' -replace '\.tar$') + if (-not (Test-Path $thisTarDestination)) { + $newDir = New-Item -ItemType Directory -Path $thisTarDestination -Force + if (-not $newDir) { return } + } + + $tarFormat = + if ( + $peekMagicBytes[0] -as [char] -eq '.' -and + $peekMagicBytes[1] -as [char] -eq '/' + ) { + # classic tarfile without gzipping + 'tar' + } elseif ($peekMagicBytes -and + $peekMagicBytes[0] -eq 31 -and + $peekMagicBytes[1] -eq 139 -and + $peekMagicBytes[2] -eq 8 + ) { + # gzipped tarball + 'tar.gz' + } else { + Write-Error "$($resolvedItem.Name) does not appear to be a .tar or .tar.gz file" + continue + } + + # If the engine supports tarfiles + if ('Formats.Tar.TarFile' -as [Type]) { + # read the file as a stream + $openFile = [IO.File]::OpenRead($resolvedItem.FullName) + if ($tarFormat -eq 'tar.gz') { + # and read that as a decompressed gzip stream + $gzipStream = [IO.Compression.GZipStream]::new($openFile, [IO.Compression.CompressionMode]'Decompress') + if (-not $gzipStream) { + $openFile.Close() + } + [Formats.Tar.TarFile]::ExtractToDirectory($gzipStream, $thisTarDestination, $true) + # Track if it worked + $worked = $? + # and close up + $gzipStream.Close() + $null = $gzipStream.DisposeAsync() + $openFile.Close() + $null = $openFile.DisposeAsync() + } else { + [Formats.Tar.TarFile]::ExtractToDirectory($openFile, $thisTarDestination, $true) + $worked = $? + $openFile.Close() + $null = $openFile.DisposeAsync() + } + + # If it worked + if ($worked) { + # pack that directory + Get-Item -LiteralPath "$thisTarDestination" | Get-OpenPackage @namedParameters + } + } elseif ($( + $tarApp = $ExecutionContext.SessionState.InvokeCommand.GetCommand('tar', 'Application') + $tarApp + )) { + # Alternatively, if the tar application installed, we can use that + $null = & $tarApp -xvf $resolvedItem.FullName -C "$thisTarDestination" + if ($?) { + # and then pack the directory + Get-Item -LiteralPath "$thisTarDestination" | Get-OpenPackage @namedParameters + } + } else { + # If neither of those paths work, write a warning. + Write-Warning "$($resolvedItem.FullName) is a tar gz, but Formats.Tar.TarFile is not loaded and `tar` app does not exist" + } +} diff --git a/Types/OpenPackage.Source/Url.ps1 b/Types/OpenPackage.Source/Url.ps1 new file mode 100644 index 0000000..336a21a --- /dev/null +++ b/Types/OpenPackage.Source/Url.ps1 @@ -0,0 +1,238 @@ +<# +.SYNOPSIS + Packages urls. +.DESCRIPTION + Gets one or more urls and stores them in Open Packages +#> +param( +# An array of urls +[Alias('Uri')] +[uri[]] +$url, + +# A list of file wildcards to include. +[Parameter(ValueFromPipelineByPropertyName)] +[SupportsWildcards()] +[string[]] +$Include, + +# A list of file wildcards to exclude. +[Parameter(ValueFromPipelineByPropertyName)] +[SupportsWildcards()] +[string[]] +$Exclude, + +# The base path within the package. +# Content should be added beneath this base path. +[string] +$BasePath = '/', + +# A content type map. +# This maps extensions and URIs to a content type. +[Collections.IDictionary] +$TypeMap = $( + ([PSCustomObject]@{PSTypeName='OpenPackage.ContentTypeMap'}).TypeMap +), + +# The compression option. +[IO.Packaging.CompressionOption] +[Alias('CompressionLevel')] +$CompressionOption = 'Superfast', + +# If set, will force the redownload of various resources and remove existing files or directories +[switch] +$Force, + +# If set, will include hidden files and folders, except for files beneath `.git` +[Alias('IncludeDotFiles')] +[switch] +$IncludeHidden, + +# If set, will include the `.git` directory contents if found. +[Alias('IncludeGitFile','IncludeGitFiles','IncludeGitDirectory')] +[switch] +$IncludeGit, + +# If set, will include any content found in `/node_modules`. +[Alias('IncludeNodeModules')] +[switch] +$IncludeNodeModule, + +# The personal data server. This is used in At Protocol requests. +[string] +$PDS, + +# Any additional headers to pass into a web request. +[Alias('Header')] +[Collections.IDictionary] +$Headers = [Ordered]@{}, + +# The current package +[IO.Packaging.Package] +$Package +) + +$namedParameters = [Ordered]@{} +foreach ($key in $MyInvocation.MyCommand.Parameters.Keys) { + $var = $ExecutionContext.SessionState.PSVariable.Get($key) + if (-not [String]::IsNullOrEmpty($var.Value)) { + $namedParameters[$key] = $var.value + } +} +$namedParameters.Remove('url') +$namedParameters.Remove('inputObject') + +foreach ($uri in $url) { + # If the uri is an at:// uri + if ($uri.Scheme -eq 'at') { + # call ourself + Get-OpenPackage -AtUri $uri @namedParameters + continue + } + + # If the URI is a git uri, or is a well-known git domain, + if ( + $uri.Scheme -eq 'git' -or + $uri.DnsSafeHost -match '^git(?>hub|lab)\.com$' -or + $uri.DnsSafeHost -match 'tangled\.(org|sh)$' -or + $uri.DnsSafeHost -match 'codeberg\.org$' -and + -not ($uri.DnsSafeHost -match '^(raw|api)') -and + -not ($uri.Segments -match '^releases/?$') + ) { + # pack a repository + Get-OpenPackage -Repository $uri @namedParameters + continue + } + + + # If the URI is a nuget.org or powershellgallery.com link. + if ($uri.DnsSafeHost -in 'nuget.org','www.nuget.org' -or + $uri.DnsSafeHost -in 'powershellgallery.com','www.powershellgallery.com' -or + $uri.DnsSafeHost -in 'community.chocolatey.org') { + # If so, call ourselves with the Nuget uri + Get-OpenPackage -NuGet $uri @namedParameters + continue + } + + # If the URI is a PyPi.org project link + if ($uri.DnsSafeHost -in 'pypi.org', 'www.pypi.org' -and + $uri.Segments[1] -eq '/project') { + # call ourselves with -PythonPackage + Get-OpenPackage -PythonPackage $uri @namedParameters + continue + } + + # If the URI is a Node Package link + if ($uri.DnsSafeHost -in + 'npmjs.com', 'www.npmjs.com', 'npmx.dev', 'www.npmx.dev' -and + $uri.Segments[1] -eq '/package' + ) { + # call ourselves with -NodePackage + Get-OpenPackage -NodePackage $uri @namedParameters + continue + } + + + # If the uri is relative + if ( + (-not $uri.IsAbsoluteUri) + ) { + $slashKey = "$uri" -replace "^\.?/?", '/' + # try to find it in the package if we have one + if ($Package -and $Package -is [IO.Packaging.Package] -and + $Package.PartExists($slashKey)) { + $Package.GetContent($slashKey) + } + + continue + } + + # At this point lets just poke at the uri and make a package. + try { + if ($headers) { + $WebResponse = Invoke-WebRequest -Uri $Uri -Headers $Headers + } else { + $WebResponse = Invoke-WebRequest -Uri $Uri + } + + } catch { + Write-Verbose "$uri - $_" + continue + } + + # If the web response is a byte array + if ($WebResponse.Content -is [byte[]] -and + # and starts with the magic pair of bytes indicate it might be a zip + ($WebResponse.Content[0] -eq 80 -and $WebResponse.Content[1] -eq 75) + ) { + # Create a stream from the response + $memoryStream = [IO.MemoryStream]::new($WebResponse.Content) + # and open the package + $currentPackage = [IO.Packaging.Package]::Open($memoryStream, 'OpenOrCreate', 'ReadWrite') + # If that did not work, it will error, + if (-not $currentPackage) { + continue # and we should continue. + } + # Attach the memory stream to the package + $currentPackage | Add-Member NoteProperty MemoryStream $memoryStream -Force + # and emit the package. + $currentPackage + } else { + # Ok, the response was not a package. + + # Let's turn it into a new package, or put it in the current package. + if (-not $package) { + $memoryStream = [IO.MemoryStream]::new() + $package = [IO.Packaging.Package]::Open($memoryStream, 'OpenOrCreate','ReadWrite') + $package.pstypenames.insert(0, 'OP') + $package.pstypenames.insert(0, 'OpenPackage') + Add-Member NoteProperty MemoryStream $memoryStream -Force -InputObject $package + } + + # Let's use the domain as the identifier + if (-not $package.PackageProperties.Identifier) { + $package.PackageProperties.Identifier = $uri.DnsSafeHost + } + + # Pick out the content type from the response + $responseType, $responseTypeOptions = $WebResponse.Headers['content-type'] -split ';' + # And get the major and minor type + $majorType, $minorType = $responseType -split '/', 2 + $minorType = $minorType -replace '^.+?\+' # replace anything in the minor type before a plus + # (this is effectively the extension) + $localPath = + # If we ask for a root uri + if ($uri.LocalPath -match '/$') { + # make it an index of the replied content type + $uri.LocalPath + 'index.' + $minorType + } else { + # otherwise, use the path they provided. + $uri.LocalPath + } + # Now that we know where we're putting it, let's create a part + $newPart = $package.CreatePart($localPath, $WebResponse.Headers['content-type'], $CompressionOption) + # and get the content stream + $newStream = $newPart.GetStream() + + # If the response was a byte array + if ($WebResponse.Content -is [byte[]]) { + # write that to the stream + $newStream.Write($WebResponse.Content, 0, $WebResponse.Content.Length) + } else { + # otherwise, turn the stringified content into bytes + $buffer = $OutputEncoding.GetBytes("$($WebResponse.Content)") + # and write that + $newStream.Write($buffer, 0, $buffer.Length) + } + + $newStream.Close() + $newStream.Dispose() + } +} + +# If there is a current package +if ($package) { + $package # output it at the end + # (this avoids returning the same package multiple times) +} + diff --git a/Types/OpenPackage.Source/Zip.ps1 b/Types/OpenPackage.Source/Zip.ps1 new file mode 100644 index 0000000..6040f46 --- /dev/null +++ b/Types/OpenPackage.Source/Zip.ps1 @@ -0,0 +1,151 @@ +<# +.SYNOPSIS + Gets a zipfile as a package +.DESCRIPTION + Gets a zipfile as an open package. +#> +param( +# The path to a tarfile. +[Parameter(Mandatory,ValueFromPipelineByPropertyName)] +[string] +$ZipFile, + +# A list of file wildcards to include. +[Parameter(ValueFromPipelineByPropertyName)] +[SupportsWildcards()] +[string[]] +$Include, + +# A list of file wildcards to exclude. +[Parameter(ValueFromPipelineByPropertyName)] +[SupportsWildcards()] +[string[]] +$Exclude, + +# A content type map. +# This maps extensions and URIs to a content type. +[Collections.IDictionary] +$TypeMap = $( + ([PSCustomObject]@{PSTypeName='OpenPackage.ContentTypeMap'}).TypeMap +), + +# The compression option. +[IO.Packaging.CompressionOption] +[Alias('CompressionLevel')] +$CompressionOption = 'Superfast', + +# If set, will force the redownload of various resources and remove existing files or directories +[switch] +$Force, + +# If set, will include hidden files and folders, except for files beneath `.git` +[Alias('IncludeDotFiles')] +[switch] +$IncludeHidden, + +# If set, will include the `.git` directory contents if found. +[switch] +$IncludeGit, + +# If set, will include any content found in `/node_modules`. +[Alias('IncludeNodeModules')] +[switch] +$IncludeNodeModule +) + +$namedParameters = [Ordered]@{} +foreach ($key in $namedParameters.Keys) { + $var = $ExecutionContext.SessionState.PSVariable.Get($key) + if ($var) { + $namedParameters[$key] = $var.value + } +} +$namedParameters.Remove('ZipFile') + +# Put tar files into their own subdirectory + +foreach ($resolvedItem in Get-Item -Path $ZipFile) { + # By reading the file with Get-Content -AsByteStream, we avoid locking the file + # (or the file being locked by another process) + + $peekMagicBytes = Get-Content -AsByteStream -LiteralPath $resolvedItem.FullName -First 5 + + if ($peekMagicBytes[0,1] -as 'char[]' -join '' -ne 'PK') { + return + } + + $packageBytes = Get-Content -LiteralPath $resolvedItem.FullName -AsByteStream -Raw + + # Create a memory stream from the byte array + $memoryStream = [IO.MemoryStream]::new($packageBytes) + # and open the package from the memory stream + $currentPackage = [IO.Packaging.Package]::Open($memoryStream, "Open", "ReadWrite") + # If that did not work, return. + if (-not $currentPackage) { return } + + $packageParts = @($currentPackage.GetParts()) + + # If we could open the file but not see the parts, it's a normal zip + if (-not $packageParts) { + # Close the package and the stream and try Expand-Archive. + $currentPackage.Close() + $memoryStream.Close() + + # To make things work, we want to extract to a folder and reload + # it's important that the folder name matches the file name, without the extension + $folderName = $resolvedItem.Name -replace '\.[^\.]+?$' + + $myAppData = if ($env:OpenPackagePath) { + # Get our first OpenPackage package path + @($env:OpenPackagePath -split $( + if ($IsLinux -or $IsMacOS) { + ':' + } else { + ';' + } + ))[0] + } else { + $pwd + } + + $folderPath = Join-Path $myAppData $folderName + # If the folder path did not exist + if (-not (Test-Path $folderPath)) { + # create it + $newDirectory = New-Item -ItemType Directory -Path $folderPath + if (-not $newDirectory) { + return + } + } + + # If the folder already exists, and we are not using force + if ((Test-Path $folderPath)) { + if (-not $Force) { + Write-Error "$FolderPath exists, use -Force" + return + } + # remove the current folder, so that no errant files show up in the package. + Remove-Item -Path $folderPath -Recurse -Force + } + + # If that worked, expand the archive to this path (giving us a fresh copy) + Expand-Archive -LiteralPath $resolvedItem.FullName -DestinationPath $folderPath + if ($?) { + # and call ourself + return Get-OpenPackage -FilePath $folderPath @namedParameters + } else { + return + } + } + + $currentPackage = $currentPackage | + Add-Member NoteProperty FilePath $filePath -Force -PassThru | + Add-Member NoteProperty MemoryStream $memoryStream -Force -PassThru + + # If there is no identifier, set it to the file name + if (-not $currentPackage.PackageProperties.Identifier) { + $currentPackage.PackageProperties.Identifier = $resolvedItem.Name + } + + $currentPackage +} diff --git a/Types/OpenPackage.View/FeatherIcon.svg.ps1 b/Types/OpenPackage.View/FeatherIcon.svg.ps1 new file mode 100644 index 0000000..75eecdc --- /dev/null +++ b/Types/OpenPackage.View/FeatherIcon.svg.ps1 @@ -0,0 +1,30 @@ +<# +.SYNOPSIS + Views Feather Icons +.DESCRIPTION + Shows a feather icon. +.NOTES + Icons will be cached in memory to avoid repeated CDN requests. +.EXAMPLE + $site.Includes.Feather "clipboard" +.LINK + https://feathericons.com/ +#> +param( +[string] +$Icon = 'chevron-right', + +[uri] +$FeatherCDN = "https://cdn.jsdelivr.net/gh/feathericons/feather@latest/icons/" +) + +if (-not $script:FeatherIconCache) { + $script:FeatherIconCache = [Ordered]@{} +} +$icon = $icon.ToLower() -replace '\.svg$' + +if (-not $script:FeatherIconCache[$icon]) { + $script:FeatherIconCache[$icon] = Invoke-RestMethod "$FeatherCDN/$Icon.svg" +} + +$script:FeatherIconCache[$icon].OuterXml diff --git a/Types/OpenPackage.View/Help.html.ps1 b/Types/OpenPackage.View/Help.html.ps1 new file mode 100644 index 0000000..064349a --- /dev/null +++ b/Types/OpenPackage.View/Help.html.ps1 @@ -0,0 +1,287 @@ +<# +.SYNOPSIS + Views Help as html +.DESCRIPTION + Views the help for Commands as html. +.NOTES + This _can_ -InvokeExample. + + You should not -InvokeExamples of commands you do not know and trust will output as html. +#> +param( +# The command +[PSObject] +$Command, + +# If set, will invoke examples. +# This can be as dangerous as your examples. +[switch] +$InvokeExample, + +# If set, will not show the command name +[switch] +$HideName +) + +# Try to get command help +$CommandHelp = Get-Help -Name $Command -ErrorAction Ignore +# If we cannot get help, return +if (-not $CommandHelp) { return } + +# If the help was a string +if ($CommandHelp -is [string]) { + # return preformatted text. + return "
$([Web.HttpUtility]::HtmlEncode($CommandHelp))
" +} + +# Get the core parts of help we need. +$synopsis = $CommandHelp.Synopsis +$description = $CommandHelp.description.text -join [Environment]::NewLine +$notes = $CommandHelp.alertSet.alert.text -join [Environment]::NewLine + +filter looksLikeMarkdown { + if ( + # If it's got a markdown-like link + $_ -match '\[[^\]]+\]\(' -or + # Or any of the lines start with markdown special characters + $_ -split '(?>\r\n|\n)' -match '\s{0,3}[\#*~`]' + ) { + # it's probably markdown + $_ + } +} + +filter looksLikeTags { + if ($_ -match '^\s{0,}<') { + $_ + } +} + +# Display the command name, synopsis, and description +if (-not $HideName) { + "

$([Web.HttpUtility]::HtmlEncode( + $CommandHelp.Name -replace '(?:.+?/){0,}' -replace '\.ps1$' -replace '\..+?$' + ))

" +} + +if ($synopsis) { + if ($synopsis | looksLikeMarkdown) { + (ConvertFrom-Markdown -InputObject $synopsis).Html + } + elseif ($synopsis | looksLikeTags) { + $synopsis + } else { + if ($page -is [Collections.IDictionary] -and -not $page.Title) { + $page.Title = $([Web.HttpUtility]::HtmlEncode($synopsis)) + } + "

$([Web.HttpUtility]::HtmlEncode($synopsis))

" + } +} + +if ($description) { + if ($description | lookslikeMarkdown) { + $markdown = (ConvertFrom-Markdown -InputObject $description).Html + if ($page -is [Collections.IDictionary] -and -not $page.Description) { + $xmarkdown = "$markdown" + $page.Description = $xmarkdown.xhtml.innerText + } + + } + elseif ($description | looksLikeTags) { + $description + } + else { + if ($page -is [Collections.IDictionary] -and -not $page.Description) { + $page.Description = $description + } + "

$([Web.HttpUtility]::HtmlEncode($description))

" + } +} + +if ($notes) { + # If there were notes, convert them from markdown + (ConvertFrom-Markdown -InputObject $notes).Html +} + +#region Grid Styles +"" +#endregion Grid Styles + +$exampleCount = @($CommandHelp.examples.example).Length +$progress = [Ordered]@{id=Get-Random} +$progress.Activity = "$($command) examples" +# Create a grid for examples +"
" +$exampleNumber = 0 +# Walk over each example +foreach ($example in $CommandHelp.examples.example) { + $exampleNumber++ + + # Combine the code and remarks + $exampleLines = + @( + $example.Code + foreach ($remark in $example.Remarks.text) { + if (-not $remark) { continue } + $remark + } + ) -join ([Environment]::NewLine) -split '(?>\r\n|\n)' # and split into lines + + # Anything until the first non-comment line is a markdown predicate to the example + $nonCommentLine = $false + $markdownLines = @() + + $progress.PercentComplete = $exampleNumber * 100 / $exampleCount + $progress.Activity = "$($command) examples $exampleNumber" + $progress.Status = "$($exampleLines[0])" + Write-Progress @progress + + # Go thru each line in the example as part of a loop + $codeBlock = @(foreach ($exampleLine in $exampleLines) { + # Any comments until the first uncommentedLine are markdown + if ($exampleLine -match '^\#' -and -not $nonCommentLine) { + $markdownLines += $exampleLine -replace '^\#' -replace '^\s+' + } else { + $nonCommentLine = $true + $exampleLine + } + }) -join [Environment]::NewLine + + # Join all of our markdown lines together + $Markdown = $markdownLines -join [Environment]::NewLine + + # and start our example div + "
" + # If we had markdown, output it + if ($markdown) { + (ConvertFrom-Markdown -InputObject $Markdown).Html + } + # followed by our sample code + "
" + "
"
+                "" + 
+                    [Web.HttpUtility]::HtmlEncode($codeBlock) + 
+                ""
+            "
" + "
" + + # If we do not want to invoke examples, we can continue to the next example. + if (-not $InvokeExample) { + "
" + "
" + continue + } + # Otherwise, try to make our example a script block + $exampleCode = + try { + [scriptblock]::Create($codeBlock) + } catch { + Write-Warning "Unable to convert $($example.code) to a script" + continue + } + + if (-not $global:ExampleOutputCache) { + $global:ExampleOutputCache = [Ordered]@{} + } + if (-not $global:ExampleOutputCache[$codeBlock]) { + $global:ExampleOutputCache[$codeBlock] = @(. $exampleCode) + } + # then run it and capture the output + $exampleOutputs = $global:ExampleOutputCache[$codeBlock] + + # Keep track of our example output count + $exampleOutputNumber = 0 + # and start walking thru the list + "
" + foreach ($exampleOutput in $exampleOutputs) { + $exampleOutputNumber++ + # Each output goes in a div + "
" + # if the output was a file + if ($exampleOutput -is [IO.FileInfo]) { + # and that file is SVG + if ($exampleOutput.Extension -eq '.svg') { + # include it inline. + Get-Content $exampleOutput.FullName -Raw + } + } else { + # If the output was a turtle object + if ($exampleOutput.pstypenames -contains 'Turtle') { + # set it's ID + $exampleOutput.ID = "$($Command)-Example-$exampleCounter" + if ($exampleOutputs.Length -gt 1) { + # If we have more than out output, + # attach our example counter + $exampleOutput.ID += "-$($exampleOutputNumber)" + } + } + # Include our example output inline + if ($exampleOutput.ToHtml.Invoke) { + $exampleOutput.ToHtml() + } else { + "$exampleOutput" + } + } + "
" + } + "
" + "
" + "
" +} +"" + +$progress.Remove('PercentComplete') +$progress['Completed'] = $true +Write-Progress @progress + +@" + +"@ \ No newline at end of file diff --git a/Types/OpenPackage.View/Help.md.ps1 b/Types/OpenPackage.View/Help.md.ps1 new file mode 100644 index 0000000..032cd69 --- /dev/null +++ b/Types/OpenPackage.View/Help.md.ps1 @@ -0,0 +1,117 @@ +<# +.SYNOPSIS + Views Help as Markdown +.DESCRIPTION + Views PowerShell Command as markdown +.INPUTS + Management.Automation.CommandInfo +#> +param() + +$allInputAndArgs = @($input) + $args + +foreach ($in in $allInputAndArgs) { + if ($in -isnot [Management.Automation.CommandInfo]) { + continue + } + + $help = $null + if ($in -is [Management.Automation.ExternalScriptInfo]) { + $help = Get-Help $in.Source + } elseif ( + $in -is [Management.Automation.FunctionInfo] -or + $in -is [Management.Automation.AliasInfo] -or + $in -is [Management.Automation.CmdletInfo] + ) { + $help = Get-Help $in.Name + } + + + if (-not $help) { + Write-Warning "Applications may not have help" + continue + } + + # If the help is a string, + if ($help -is [string]) { + # make it preformatted text + "~~~" + "$export" + "~~~" + } else { + # Otherwise, add list the export + "### $($export)" + + # And make it's synopsis a header + "#### $($help.SYNOPSIS)" + + # put the description below that + "$($help.Description.text -join [Environment]::NewLine)" + + # Show our examples + "##### Examples" + + $exampleNumber = 0 + foreach ($example in $help.examples.example) { + $markdownLines = @() + $exampleNumber++ + $nonCommentLine = $false + "###### Example $exampleNumber" + + # Combine the code and remarks + $exampleLines = + @( + $example.Code + foreach ($remark in $example.Remarks.text) { + if (-not $remark) { continue } + $remark + } + ) -join ([Environment]::NewLine) -split '(?>\r\n|\n)' # and split into lines + + # Go thru each line in the example as part of a loop + $codeBlock = @(foreach ($exampleLine in $exampleLines) { + # Any comments until the first uncommentedLine are markdown + if ($exampleLine -match '^\#' -and -not $nonCommentLine) { + $markdownLines += $exampleLine -replace '^\#\s{0,1}' + } else { + $nonCommentLine = $true + $exampleLine + } + }) -join [Environment]::NewLine + + $markdownLines + "~~~PowerShell" + $CodeBlock + "~~~" + } + + $relatedUris = foreach ($link in $help.relatedLinks.navigationLink) { + if ($link.uri) { + $link.uri + } + } + if ($relatedUris) { + "#### Links" + foreach ($related in $relatedUris) { + "* [$related]($related)" + } + } + + # Make a table of parameters + if ($help.parameters.parameter) { + "##### Parameters" + + "" + + "|Name|Type|Description|" + "|-|-|-|" + foreach ($parameter in $help.Parameters.Parameter) { + "|$($parameter.Name)|$($parameter.type.name)|$( + $parameter.description.text -replace '(?>\r\n|\n)', '
' + )|" + } + + "" + } + } +} \ No newline at end of file diff --git a/Types/OpenPackage.View/Markdown.html.ps1 b/Types/OpenPackage.View/Markdown.html.ps1 new file mode 100644 index 0000000..de8ee3a --- /dev/null +++ b/Types/OpenPackage.View/Markdown.html.ps1 @@ -0,0 +1,142 @@ +<# +.SYNOPSIS + Views Markdown as HTML +.DESCRIPTION + Views Markdown as HTML +.EXAMPLE + +#> +[OutputType('text/html')] +[PSTypeName('string')] +[PSTypeName('IO.Packaging.Package')] +[PSTypeName('IO.Packaging.PackagePart')] +param() + +# Quickly enumerate all input and arguments. +$allInput = @($input) + @($args) + +# If there is no pipeline builder +if (-not ('Markdig.MarkdownPipelineBuilder' -as [type])) { + # warn and return + Write-Warning "Markdig not loaded (ConvertFrom-Markdown is not installed)" + return +} + +# Create the pipeline builder +$mdPipelineBuilder = [Markdig.MarkdownPipelineBuilder]::new() +$mdPipeline = [Markdig.MarkdownExtensions]::UsePipeTables($mdPipelineBuilder).Build() + +# Make one quick pass over all input +$allInput = @( + foreach ($in in $allInput) { + # and expand any packages we find into their parts. + if ($in -is [IO.Packaging.Package]) { + $in.GetParts() + } else { + $in + } + } +) + +# Next make a neat little filter to decorate our output +# We can give PowerShell objects N arbitrary types +# including actual content types +filter text/html { + # Just check if our typenames do not contain our invocation name + if ($_.pstypenames -and + $_.pstypenames -notcontains $myInvocation.InvocationName) { + # and add that typename. + $_.pstypenames.add($myInvocation.InvocationName) + } + $_ # and pass thru the input. +} + +# Now, let's go over all input +:nextInput foreach ($in in $allInput) { + # If the input is a string + if ($in -is [string]) { + # Just make the markdown html + # and stick it in an
tag + "
$( + [Markdig.Markdown]::ToHtml($in, $mdPipeline) + )
" | text/html + # and continue to the next input. + continue nextInput + } + + # If the input is a package part, we can do more + if ( + $in -is [IO.Packaging.PackagePart] -and + # (if it is not a markdown file, we should ignore it). + $in.Uri -match '(?>\.md|\.markdown)$' + ) { + # Let's read our input real quick: + $partStream = $in.GetStream('Open', 'Read') + $reader = [IO.StreamReader]::new($partStream) + # Just store the markdown. + $markdown = $reader.ReadToEnd() + + # and clean up. + $reader.Close(), $reader.Dispose() + $partStream.Close(), $partStream.Dispose() + + # We stick the markdown into an article tag + $markdownHtml = "
$( + $( + [Markdig.Markdown]::ToHtml($markdown, $mdPipeline) + ) + )
" + + $markdownxhtml = $markdownHtml -as [xml] + + foreach ($link in $markdownxhtml | Select-Xml //a) { + if ($link.Node.href -match '\.(?>md|markdown)$') { + $link.Node.href = $link.Node.href -replace '^(?.+?)\.(?>md|markdown)$', + '../${Name}/' + } + } + + # And we can use relationships to determine more. + $part = $in + + # If the part had a 'palette' relationship + $paletteUrl = + # If the part had a 'palette' relationship + if ($part.RelationshipExists('palette')) { + # use that target uri + $part.GetRelationship('palette').TargetUri + } elseif ($part.Package.RelationshipExists('palette')) { + # alternatively, if the package had a palette, use that. + $part.Package.GetRelationship('palette').TargetUri + } + + # Now, we can put the markdown into HTML. + $html = @( + "" + # The title is easy. + "" + $([Web.HttpUtility]::HtmlEncode($part.Name)) + "" + # If there's a palette url + if ($paletteUrl) { + # link the stylesheet with that id. + "" + } + "" + "" + $markdownxhtml.OuterXml + "" + "" + ) -join [Environment]::NewLine + + $html | + Add-Member NoteProperty Package $in.Package -Force -PassThru | + Add-Member NoteProperty PartUri $in.Uri -Force -PassThru | + Add-Member NoteProperty Part $in -Force -PassThru | + text/html + + # continue to the next input. + continue nextInput + } +} + diff --git a/Types/OpenPackage.View/Tree.html.ps1 b/Types/OpenPackage.View/Tree.html.ps1 new file mode 100644 index 0000000..1a119fd --- /dev/null +++ b/Types/OpenPackage.View/Tree.html.ps1 @@ -0,0 +1,81 @@ +<# +.SYNOPSIS + Shows a package tree as HTML +.DESCRIPTION + Shows a package tree beneath a point as HTML +#> +[OutputType('text/html')] +[PSTypeName('IO.Packaging.Package')] +param( +# Any input +[Parameter(ValueFromPipeline)] +[Alias('Package')] +[PSObject[]] +$InputObject, + +# The file pattern +[string] +$FilePattern = '.', + +# The CSS of the tree +[Alias('CSS')] +[string] +$Style = @" +.directory-name {font-size: 1.5rem;} +.directory { font-size: 1.25rem; } +"@ +) + + +$allInput = @($input) +if ((-not $allInput) -and $InputObject) { + $allInput += $InputObject +} + +filter toIndex { + $in = $_ + if ($in -is [Collections.IDictionary]) { + "
    " + $in.GetEnumerator() | toIndex + "
" + } elseif ($in.Key -is [string]) { + + if ($in.Value -is [Collections.IDictionary]) { + "
  • " + "$([Web.HttpUtility]::HtmlEncode($in.Key))" + $in.Value | toIndex + "
  • " + } else { + "
  • " + "" + [Web.HttpUtility]::HtmlEncode($in.Key) + "" + "
  • " + } + } +} + +foreach ($in in $allInput) { + if (-not $in.GetTree) { + continue + } + + $fileTree = $in.GetTree($FilePattern) + @( + if ($Style) { + "" + } + $fileTree | toIndex + ) -join [Environment]::NewLine | + Add-Member NoteProperty Package $in -Force -PassThru | + Add-Member NoteProperty FilePattern $in -Force -PassThru | + . { + process { + $_.pstypenames.add('text/html') + $_ + } + } +} + diff --git a/Types/OpenPackage.View/Tree.md.ps1 b/Types/OpenPackage.View/Tree.md.ps1 new file mode 100644 index 0000000..e4899c9 --- /dev/null +++ b/Types/OpenPackage.View/Tree.md.ps1 @@ -0,0 +1,111 @@ +<# +.SYNOPSIS + Shows a markdown package tree +.DESCRIPTION + Shows a package tree as markdown. +.NOTES + Will ignore any input that is not a package +#> +[OutputType('text/markdown')] +param( +# Any input. +# If this is not a package, or does not contain a package, the input will be ignored. +[Parameter(ValueFromPipeline)] +[Alias('Package')] +[PSObject] +$InputObject, + +# The file pattern. All files that match this pattern will be urn. +[string] +$FilePattern = '.' +) + +$allInput = @($input) +if ((-not $allInput) -and $InputObject) { + $allInput += $InputObject +} + + +filter toIndex { + $in = $_ + + if ($in -is [Collections.IDictionary]) { + $in.GetEnumerator() | . toIndex + } elseif ($in.Key -is [string]) { + $prefix = "$(' ' * $depth * 2)* " + $depth++ + if ($in.Value -is [Collections.IDictionary]) { + if ( + $in.Value.Keys -match 'index\..+?$' -and + $in.Value.Values.Uri + ) { + $prefix + "[$($in.Key)]($( + $rootUrl + + @( + @($in.Value.Values.Uri) -match 'index\..+?$' + )[0] -replace 'index\..+?$' + ))" + } else { + $prefix + $in.Key + } + + $in.Value | . toIndex + } elseif ($in.Value.Uri) { + $prefix + "[$($in.Key)]($($rootUrl)$($in.Value.Uri))" + } elseif ($in.Value.FullName) { + $prefix + "[$($in.Key)]($($in.Value.FullName))" + } else { + $prefix + $in.Key + } + $depth-- + } + +} + +foreach ($in in $allInput) { + $fileTree = $null + $rootUrl = '' + if ($in.GetTree) { + $fileTree = $in.GetTree($FilePattern) + } + if ($in.Package.GetTree) { + $fileTree = $in.Package.GetTree($FilePattern) + } + if ($in.Url) { + $rootUrl = $in.Url + } + + if ($in -is [IO.DirectoryInfo]) { + $filelist = @(Get-ChildItem -Path $in -File -Recurse) + $fileTree = [Ordered]@{} + foreach ($fileInfo in $filelist) { + $relativePath = $fileInfo.FullName.Substring($in.FullName.Length + 1) + $pointer = $fileTree + $hierarchy = @($relativePath -split '[\\/]') + for ($index = 0; $index -lt ($hierarchy.Length - 1); $index++) { + $subdirectory = $hierarchy[$index] + if (-not $pointer[$subdirectory]) { + $pointer[$subdirectory] = [Ordered]@{} + } + $pointer = $pointer[$subdirectory] + } + $pointer[$hierarchy[-1]] = $fileInfo + } + } + + if (-not $fileTree.Count) { + continue + } + @( + $depth = 0 + $fileTree | toIndex + ) -join [Environment]::NewLine | + Add-Member NoteProperty Package $in -Force -PassThru | + Add-Member NoteProperty FilePattern $in -Force -PassThru | + . { + process { + $_.pstypenames.add('text/markdown') + $_ + } + } +} \ No newline at end of file diff --git a/Types/OpenPackage.View/Tree.txt.ps1 b/Types/OpenPackage.View/Tree.txt.ps1 new file mode 100644 index 0000000..dd9fb3c --- /dev/null +++ b/Types/OpenPackage.View/Tree.txt.ps1 @@ -0,0 +1,87 @@ +<# +.SYNOPSIS + Shows a text package tree +.DESCRIPTION + Shows a package tree as text. +#> +[OutputType('text/plain')] +param( +# Any input +[Parameter(ValueFromPipeline)] +[Alias('Package')] +[PSObject] +$InputObject, + +# The file pattern. All files that match this pattern will be urn. +[string] +$FilePattern = '.', + +# The symbol used for roots of the tree +[string] +$RootSymbol = '╮', + +# The symbol used for branches of the tree +[string] +$BranchSymbol = "─", + +# The number of characters a branch should take +[int] +$BranchSize = 2, + +# The symbol used for a leaf of the tree. +[string] +$LeafSymbol = '├' +) + +$allInput = @($input) +if ((-not $allInput) -and $InputObject) { + $allInput += $InputObject +} + +filter toIndex { + $in = $_ + + if ($in -is [Collections.IDictionary]) { + $in.GetEnumerator() | . toIndex + } elseif ($in.Key -is [string]) { + $depth++ + if ($in.Value -is [Collections.IDictionary]) { + + '' + ( + (' ' * ($BranchSize + $LeafSymbol.Length + $RootSymbol.Length - 1)) * ( + $depth - 1 + ) + ) + $LeafSymbol + ( + $BranchSymbol * $BranchSize + ) + $RootSymbol + ' ' + $in.Key + + $in.Value | . toIndex + } else { + ( + ' ' * ($BranchSize + $LeafSymbol.Length + $RootSymbol.Length - 1) + ) * ($depth - 1) + $LeafSymbol + $in.Key + } + $depth-- + } + +} + + +foreach ($in in $allInput) { + if (-not $in.GetTree) { + continue + } + $fileTree = $in.GetTree($FilePattern) + @( + $depth = 0 + $fileTree | toIndex + ) -join [Environment]::NewLine | + Add-Member NoteProperty Package $in -Force -PassThru | + Add-Member NoteProperty FilePattern $in -Force -PassThru | + . { + process { + $_.pstypenames.add('text/plain') + $_ + } + } +} \ No newline at end of file diff --git a/Types/OpenPackage.View/at.markpub.markdown.ps1 b/Types/OpenPackage.View/at.markpub.markdown.ps1 new file mode 100644 index 0000000..5d67bc6 --- /dev/null +++ b/Types/OpenPackage.View/at.markpub.markdown.ps1 @@ -0,0 +1,158 @@ +<# +.SYNOPSIS + Views input as `at.markpub.markdown` +.DESCRIPTION + Views any applicable input object as a `at.markpub.markdown` object +.INPUTS + OpenPackage +.INPUTS + OpenPackage.Part +.INPUTS + string +.LINK + https://markpub.at/ +#> +param() + +$allInput = @($input) + @($args) + +# Make one quick pass over all input +$allInput = @( + foreach ($in in $allInput) { + # and expand any packages we find into their parts. + if ($in -is [IO.Packaging.Package]) { + $in.GetParts() + } + elseif ($in.Package -is [IO.Packaging.Package]) { + $in.Package.GetParts() + } + elseif ($in) { + $in + } + } +) + +# Declare a filter + +filter at.markpub.markdown { + $in = $_ + + $frontMatter = [Ordered]@{} + + if ($in.Package -and $in.PartUri) { + $inPart = $in.Package.GetPart($in.PartUri) + $frontMatter['title'] = $inPart.Name -replace '[\-_]', ' ' + $frontMatter['path'] = + $in.PartUri -replace '(?>/README|/index)?\.(?>md|markdown)$' + + $inPart = $in.Package.GetPart($in.PartUri) + $inputMetadata = $inPart.Metadata + foreach ($metadata in $inputMetadata) { + if ($metadata -is [Collections.IDictionary]) { + if ($metadata.PrivateData.PSData -is [Collections.IDictionary]) { + $metadata = $metadata.PrivateData.PSData + } + foreach ($key in @($metadata.Keys | Sort-Object)) { + if (-not $frontMatter.Contains($key)) { + $frontMatter[$key] = $metadata[$key] + } else { + $frontMatter[$key] = + @($metadata[$key]) + + $frontMatter[$key] + } + } + } + elseif ($metadata -isnot [string] -and + $metadata -isnot [xml]) { + foreach ($property in $metadata.psobject.properties) { + if (-not $frontMatter.Contains($property.Name)) { + $frontMatter[$property.Name] = $metadata.($property.Name) + } else { + $frontMatter[$property.Name] = + @($metadata.($property.Name)) + + $frontMatter[$property.Name] + } + } + } + } + } + + + + [PSCustomObject]@{ + PSTypeName = 'at.markpub.markdown' + '$type' = 'at.markpub.markdown' + 'text' = [PSCustomObject]@{ + PSTypeName = 'at.markpub.text' + '$type' = 'at.markpub.text' + 'markdown' = + if ($in -is [string]) { + $in + } elseif ($in.markdown) { + $in.markdown + } else { + '' + } + } + 'frontMatter' = [PSCustomObject]$frontMatter + } +} + +# Now, let's go over all input +:nextInput foreach ($in in $allInput) { + # If the input is a string + if ($in -is [string]) { + # Just take the markdown, + # put it in an at.markpub.markdown object, + # and continue to the next input. + $in | at.markpub.markdown + continue nextInput + } + + if ($in.'$type' -eq 'at.markpub.markdown') { + $in + continue nextInput + } + + # If the input is a package part, we can do more + if ( + $in -is [IO.Packaging.PackagePart] -and + # (if it is not a markdown file, we should ignore it). + $in.Uri -match '(?>\.md|\.markdown)$' + ) { + # If there is no reader + if (-not $in.Reader) { + # write a warning and continue to the next input. + Write-Warning "No reader for '$($in.Uri)'" + continue nextInput + } + # Read our input and make it `at.markpub.markdown` + $in.Read() | + at.markpub.markdown + # continue to the next input. + continue nextInput + } + + if ($in -is [IO.Packaging.PackagePart] -and + $in.Uri -match '(?>\.json)$' + ) { + # If there is no reader + if (-not $in.Reader) { + # write a warning and continue to the next input. + Write-Warning "No reader for '$($in.Uri)'" + continue nextInput + } + + foreach ($value in $in.Read()) { + if ($value.'$type' -eq 'at.markpub.markdown') { + $value + } + elseif ($value.value.'$type' -eq 'at.markpub.markdown') { + $value.value + } + elseif ($value.record.'$type' -eq 'at.markpub.markdown') { + $value.record + } + } + } +} \ No newline at end of file diff --git a/Types/OpenPackage.View/org.poshweb.op.ps1 b/Types/OpenPackage.View/org.poshweb.op.ps1 new file mode 100644 index 0000000..f79b65a --- /dev/null +++ b/Types/OpenPackage.View/org.poshweb.op.ps1 @@ -0,0 +1,111 @@ +<# +.SYNOPSIS + Views a package as an `org.poshweb.op` +.DESCRIPTION + Views a package as an `org.poshweb.op`. + + This provides some summary metadata about an open package, + and contains basic information about files in the package. +.INPUTS + System.IO.Packaging.Package +#> +[Reflection.AssemblyMetadata('schema', @' +{ + "$schema": "https://json-schema.org/schema", + "$id": "https://op.poshweb.org/schemas/org.poshweb.op", + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string", + "description": "The package id" + }, + "creator": { + "type": "string", + "description": "The package creator" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "files": { + "type": "array", + "items": { + "type": "object", + "required": ["algorithm", "contentType", "hash", "path"], + "properties": { + "algorithm": { + "type": "string", + "description": "The hash algorithm" + }, + "hash": { + "type": "string", + "description": "The hash value" + }, + "contentType": { + "type": "string", + "description": "The content type" + }, + "path": { + "type": "string", + "description": "The path of the hashed content" + } + } + } + }, + "version": { + "type": "string", + "description": "The package version" + }, + "url": { + "type": "string", + "format": "url", + "description": "The package url" + } + } +} +'@ +)] +param() + +$allInputAndArgs = @($input) + @($args) + +$mySchema = foreach ($attribute in $MyInvocation.MyCommand.ScriptBlock.Attributes) { + if ($attribute.Key -in 'schema', 'jsonschema','json-schema') { + $attribute.Value | ConvertFrom-Json + break + } +} + +foreach ($in in $allInputAndArgs) { + if ($in -isnot [IO.Packaging.Package]) { + continue + } + + [PSCustomObject]@{ + PSTypeName = 'org.poshweb.op' + '$schema' = "$($mySchema.'$schema')" + '$id' = "$($mySchema.'$id')" + '$type' = 'org.poshweb.op' + id = "$($in.Identifier)" + description = "$($in.description)" + version = "$($in.Version)" + files = @( + foreach ($part in $in.GetParts()) { + $part.GetHash() | + Select-Object @{ + name='path';expression={$_.path} + }, @{ + name='contentType';Expression={$part.ContentType} + }, @{ + name='hash';expression={$_.hash.ToLower()} + }, @{ + name='algorithm';expression={$_.algorithm.ToLower()} + } + } + ) + tags = @($in.Keywords -split '\s+') + } +} \ No newline at end of file diff --git a/Types/OpenPackage.View/site.standard.document.ps1 b/Types/OpenPackage.View/site.standard.document.ps1 new file mode 100644 index 0000000..40eba93 --- /dev/null +++ b/Types/OpenPackage.View/site.standard.document.ps1 @@ -0,0 +1,146 @@ +<# +.SYNOPSIS + Views input as `standard.site.document` +.DESCRIPTION + Views any applicable input object as a `standard.site.document` object +.INPUTS + OpenPackage +.INPUTS + OpenPackage.Part +.INPUTS + string +.LINK + https://standard.site/ +#> +param() + +$allInput = @($input) + @($args) + +# Make one quick pass over all input +$allInput = @( + foreach ($in in $allInput) { + # and expand any packages we find into their parts. + if ($in -is [IO.Packaging.Package]) { + $in.GetParts() + } + elseif ($in.Package -is [IO.Packaging.Package]) { + $in.Package.GetParts() + } + else { + $in + } + } +) + +# Declare a filter + +# Now, let's go over all input +:nextInput foreach ($in in $allInput) { + # If the input is a string + if ($in -is [string]) { + # Just take the markdown + # and put it in an at.markpub.markdown object + + # and continue to the next input. + $in | at.markpub.markdown + continue nextInput + } + + # If the input has a `$type`, and it is `site.standard.document` + if ($in.'$type' -eq 'site.standard.document') { + $in # pass the input thru + continue nextInput # and continue to the next input. + } + + # If the input is a package part, we can do more + if ( + $in -is [IO.Packaging.PackagePart] -and + # (if it is not a markdown file, we should ignore it). + $in.Uri -match '(?>\.md|\.markdown)$' + ) { + # Get our markdown files as `at.markpub.markdown` + $atMarkPub = $in | Format-OpenPackage -View at.markpub.markdown + # and create a standard site document to hold them. + $standardSiteDocument = [Ordered]@{'$type' = 'site.standard.document'} + # the content is the `at.markpub.markdown` + $standardSiteDocument.content = $atMarkPub + # and we want to propagate various front-matter into the site: + if ($atMarkPub.frontMatter) { + # * `title` + if ($atMarkPub.frontMatter.title) { + $standardSiteDocument.title = $atMarkPub.frontMatter.title + } + + # * `description` + if ($atMarkPub.frontMatter.description) { + $standardSiteDocument.title = $atMarkPub.frontMatter.description + } + + # * `tags` + if ($atMarkPub.frontMatter.tags) { + $standardSiteDocument.tags = @( + $atMarkPub.frontMatter.tags + ) + } + + # * `path` + if ($atMarkPub.frontMatter.path) { + $standardSiteDocument.path = + $atMarkPub.frontMatter.path -replace '^/?', '/' + } + + # * `image` to `coverImage` + if ($atMarkPub.frontMatter.image) { + # * Please note that at this point our image should be a url. + $standardSiteDocument.coverImage = $atMarkPub.frontMatter.image + } + + # * `bskyPostRef` + if ($atMarkPub.frontMatter.bskyPostRef) { + $standardSiteDocument.bskyPostRef = $atMarkPub.frontMatter.bskyPostRef + } + } + + # Now get our inner text + $innerText = + ("
    $( + $atMarkPub.text.markdown | + ConvertFrom-Markdown | + Select-Object -Expand Html + )
    " -as [xml]).article.innerText + + # set the text content + if ($innerText) { + $standardSiteDocument.textContent = $innerText + } + + + # Make it an object, and emit the standard site document. + [PSCustomObject]$standardSiteDocument + # Then continue to the next input. + continue nextInput + } + + if ($in -is [IO.Packaging.PackagePart] -and + $in.Uri -match '(?>\.json)$' + ) { + # If there is no reader + if (-not $in.Reader) { + # write a warning and continue to the next input. + Write-Warning "No reader for '$($in.Uri)'" + continue nextInput + } + + foreach ($value in $in.Read()) { + if ($value.'$type' -eq 'site.standard.document') { + $value + } + elseif ($value.value.'$type' -eq 'site.standard.document') { + $value.value + } + elseif ($value.record.'$type' -eq 'site.standard.document') { + $value.record + } + } + } +} \ No newline at end of file diff --git a/Types/OpenPackage/Alias.psd1 b/Types/OpenPackage/Alias.psd1 new file mode 100644 index 0000000..6ef1788 --- /dev/null +++ b/Types/OpenPackage/Alias.psd1 @@ -0,0 +1,10 @@ +@{ + RemovePart = "DeletePart" + Lexicons = "Lexicon" + '11ty' = "Eleventy" + 'Underbar' = 'Underscore' + '_' = 'Underscore' + 'tsconfig.json' = 'TypeScriptConfig.json' + 'manifest.psd1' = 'PowerShellManifest' + 'Include' = 'Includes' +} \ No newline at end of file diff --git a/Types/OpenPackage/DefaultDisplay.txt b/Types/OpenPackage/DefaultDisplay.txt new file mode 100644 index 0000000..6044d11 --- /dev/null +++ b/Types/OpenPackage/DefaultDisplay.txt @@ -0,0 +1,2 @@ +Identifier +FileList \ No newline at end of file diff --git a/Types/OpenPackage/GetContent.ps1 b/Types/OpenPackage/GetContent.ps1 new file mode 100644 index 0000000..9061652 --- /dev/null +++ b/Types/OpenPackage/GetContent.ps1 @@ -0,0 +1,103 @@ +<# +.SYNOPSIS + Gets part content +.DESCRIPTION + Gets the content of one or more parts. + + Will attempt to get the content in the best type available, using any available reader. + + Otherwise, will attempt to convert xml, and, if that fails, will return content bytes. +.NOTES + Yaml support requires the installation of an appropriate ConvertFrom-Yaml command + + [YaYaml](https://github.com/jborean93/PowerShell-Yayaml/) is recommended. + + Toml support requires the installation of an appropriate ConvertFrom-Toml command + + [PSToml](https://github.com/jborean93/PSToml) is recommended. +#> +param() + +$unrolledArgs = @(foreach ($arg in $args) { + $arg +}) + +filter addPackageAndPart { + $_ | + Add-Member NoteProperty Package $this -Force -PassThru | + Add-Member NoteProperty PartUri $thisPart.Uri -Force -PassThru +} + +$myParts = @($this.GetParts()) + +$matchingParts = + foreach ($arg in $unrolledArgs) { + if ($arg -is [string]) { + $SlashPart = '/' + ($arg -replace '^/') + if ($this.PartExists($SlashPart)) { + $this.GetPart($SlashPart) + } elseif ($arg -match '\*') { + @($myParts.Uri) -like $arg + } + } + } + +:nextPart foreach ($part in $matchingParts) { + $thisPart = $part + + # If we have a reader, just read the content. + if ($part.Reader) { + $part.Read() + continue nextPart + } + + $partStream = $thisPart.GetStream() + + $memoryStream = [IO.MemoryStream]::new() + $partStream.CopyTo($memoryStream) + $partStream.Close() + $partStream.Dispose() + + [byte[]]$partBytes = $memoryStream.ToArray() + + $memoryStream.Close() + $memoryStream.Dispose() + + $byteStream = [IO.MemoryStream]::new($partBytes) + $partStreamReader = [IO.StreamReader]::new($byteStream) + $partString = $partStreamReader.ReadToEnd() + $partStreamReader.Close() + $partStreamReader.Dispose() + $byteStream.Close() + $byteStream.Dispose() + + $partAsXml = $partString -as [xml] + if ($null -ne $partAsXml) { + if ($partASXml.Objs) { + try { + [Management.Automation.PSSerializer]::Deserialize($partString) | + addPackageAndPart + continue nextPart + } catch { + Write-Warning "$($thisPart.Uri) was not clixml" + } + } else { + $partAsXml | addPackageAndPart + } + + continue nextPart + } + + if ($thisPart.Uri -match './astro$') { + $partString | + addPackageAndPart + continue nextPart + } + + if ($thisPart.ContentType -match '^text/') { + $partString | + addPackageAndPart + } else { + $partBytes + } +} \ No newline at end of file diff --git a/Types/OpenPackage/GetTree.ps1 b/Types/OpenPackage/GetTree.ps1 new file mode 100644 index 0000000..7b7c183 --- /dev/null +++ b/Types/OpenPackage/GetTree.ps1 @@ -0,0 +1,45 @@ +<# +.SYNOPSIS + Gets the package tree +.DESCRIPTION + Gets the tree of all files in a package matching a pattern. +#> +[OutputType([Collections.IDictionary])] +param( +# The file pattern +[string] +$FilePattern = '.' +) + +if (-not $this.getParts) { return } + +# Get all of the files in the tree. +$filesInTree = @( + foreach ($part in $this.GetParts()) { + if ($part.Uri -match $filePattern) { + $part + } + } +) + +# If there were no files, return an empty directory. +$fileCount = $filesInTree.Length +if (-not $fileCount) { return [Ordered]@{} } + +$fileTree = [Ordered]@{} +foreach ($filePart in $filesInTree) { + $relativePath = $filePart.Uri + $hierarchy = @($relativePath -replace '^/' -split '[\\/]' -ne '') + $pointer = $fileTree + for ($index = 0; $index -lt ($hierarchy.Length - 1); $index++) { + $subdirectory = $hierarchy[$index] + if (-not $pointer[$subdirectory]) { + $pointer[$subdirectory] = [Ordered]@{} + } + $pointer = $pointer[$subdirectory] + } + $pointer[$hierarchy[-1]] = $filePart +} + + +return $fileTree diff --git a/Types/OpenPackage/Match.ps1 b/Types/OpenPackage/Match.ps1 new file mode 100644 index 0000000..b077bef --- /dev/null +++ b/Types/OpenPackage/Match.ps1 @@ -0,0 +1,57 @@ +<# +.SYNOPSIS + Matches content in a package +.DESCRIPTION + Matches content in a package. + + Takes two regular expression patterns: + + * A File Pattern + * A Content Pattern + + If neither is provided, lists parts + + If only the file pattern is provided, lists parts the match the pattern. + + If both the file and content pattern are provided, outputs matches. +#> +param( +$FilePattern, +$ContentPattern +) + +if ($ContentPattern -and $ContentPattern -isnot [Regex]) { + $ContentPattern = [Regex]::new($ContentPattern,'IgnoreCase', '00:00:05') +} + +if (-not $this.GetParts) { return } + +:nextPart foreach ($part in $this.GetParts()) { + if ($filePattern -and ($part.Uri -notmatch $FilePattern)) { + continue nextPart + } + if (-not $ContentPattern) { + $part + continue nextPart + } + + $partStream = $part.GetStream() + + if (-not $partStream ) { continue } + + $partStreamReader = [IO.StreamReader]::new($partStream) + + $partText = $partStreamReader.ReadToEnd() + + + + $partStreamReader.Close() + $partStreamReader.Dispose() + + $partStream.Close() + $partStream.Dispose() + + $ContentPattern.Matches($partText) | + Add-Member NoteProperty Uri $part.Uri -Force -PassThru | + Add-Member NoteProperty Package $this +} \ No newline at end of file diff --git a/Types/OpenPackage/PSTypeName.txt b/Types/OpenPackage/PSTypeName.txt new file mode 100644 index 0000000..241cd33 --- /dev/null +++ b/Types/OpenPackage/PSTypeName.txt @@ -0,0 +1 @@ +System.IO.Packaging.Package \ No newline at end of file diff --git a/Types/OpenPackage/README.md b/Types/OpenPackage/README.md new file mode 100644 index 0000000..07ef583 --- /dev/null +++ b/Types/OpenPackage/README.md @@ -0,0 +1,28 @@ +# Open Package + +Anything can become a package. + +## Open Packaging Conventions + +The [Open Package Conventions](https://en.wikipedia.org/wiki/Open_Packaging_Conventions) are an Open Protocol for Open Packages. + +They were first published in 2006, in [ECMA-376](https://ecma-international.org/publications-and-standards/standards/ecma-376/) + +Like many file formats, Open Packages are zip files in a trenchcoat. + +In PowerShell we can work with Open Packages using .NET type [`System.IO.Packaging.Package`](https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties?view=windowsdesktop-10.0&wt.mc_id=MVP_321542) + +In PowerShell, we can also extend type data using either Update-TypeData or a .types.ps1xml file. + +This is how OP works. + +It extends what you can do with OpenPackages in PowerShell, uses Open Protocols to read and write to packages. + +There are _quite_ a lot of things you can do with an Open Package, and we will add more with time. + +To explore what you can do with an open package, use the PowerShell command `Get-Member`: + +~~~PowerShell +# Get an empty package and get its methods and properties. +Get-OpenPackage | Get-Member +~~~ \ No newline at end of file diff --git a/Types/OpenPackage/Relate.ps1 b/Types/OpenPackage/Relate.ps1 new file mode 100644 index 0000000..09031cf --- /dev/null +++ b/Types/OpenPackage/Relate.ps1 @@ -0,0 +1,56 @@ +<# +.SYNOPSIS + Adds Relationships to a Package +.DESCRIPTION + Simplifies adding relationships to a package. + + All relationships are external. + + If a relationship type is not provided, will default to "unknown" + + If no id is provided, will default to the relationship type. + + If relations with that type already exists, will append a counter to the id. +#> +param( +# The relationship uri +[Parameter(Mandatory)] +[uri]$uri, +# The relationship type. +# If this is not provided, will default to `unknown` +[string]$type, +# The relationship id +# If not provided, will default to the type. +# If any relationships of the type already exist, will append a counter to the id. +[string]$id +) + +# Return if we cannot have relationships. +if (-not $this.GetRelationships) { return } +if (-not $this.CreateRelationship) { return } +# Default the type to `unknown` +if (-not $type) { $type = 'unknown'} +# and default the `$id` to the `$type`. +if (-not $id) { $id = $type } + +# Get our relations +$relations = @( + foreach ($relation in $this.GetRelationships()) { + if ($relation.RelationshipType -eq $type) { + $relation + } + } +) + +# If we have no relations, +if (-not $relations) { + # hard relate. + $this.CreateRelationship( + $uri, 'External', $type, $id + ) +} else { + # Otherwise, create another relationship of the same type. + $this.CreateRelationship( + $uri, 'External', $type, "$($id)$($relations.Count)" + ) +} \ No newline at end of file diff --git a/Types/OpenPackage/SetContent.ps1 b/Types/OpenPackage/SetContent.ps1 new file mode 100644 index 0000000..9e1c450 --- /dev/null +++ b/Types/OpenPackage/SetContent.ps1 @@ -0,0 +1,82 @@ +<# +.SYNOPSIS + Sets package content +.DESCRIPTION + Sets content in an open package. + + Will overwrite existing entries. +#> +param( +# The package uri +[uri] +$Uri, + +# The package content +[PSObject] +$Content, + +# The content type +[string] +$ContentType, + +# Any options +[Alias('Options')] +[Collections.IDictionary] +$Option = [Ordered]@{} +) + +$part = + if ($InputObject.PartExists($uri)) { + $InputObject.GetPart($uri) + } else { + $InputObject.CreatePart($uri, $ContentType) + } + +if (-not $?) { return } + +if ($part.Writer -and $part.Write) { + $part.Write($Content, $Option) + return +} + +# Get the stream +$partStream = $part.GetStream() + +if (-not $partStream) { return } +# zero out the stream, so we do not underwrite content +$partStream.SetLength(0) +# First see if the content is a byte[] +if ($content -is [byte[]]) { + # if so, just write it + $partStream.Write($content, 0, $content.Length) +} +# If the content is a stream, +elseif ($content -is [IO.Stream]) { + # copy it in. + $content.CopyTo($partStream) +} +# If the content was xml or could be, +elseif ($content -is [xml] -or ($contentXml = $content -as [xml])) { + if ($contentXml) { $content = $contentXml } + $content.Save($partStream) +} elseif ($content -is [string]) { + # Put strings in as a byte array. + $buffer = $OutputEncoding.GetBytes($content) + $partStream.Write($buffer, 0, $buffer.Length) +} elseif ($contentBytes = $content -as [byte[]]) { + # Bytes are obviously a byte array + $partStream.Write($contentBytes, 0, $contentBytes.Length) +} +elseif ($ContentType -match '[/\+]json') { + # Explicitly typed json can be converted to json + $buffer = $OutputEncoding.GetBytes((ConvertTo-Json -InputObject $content -Depth 10)) + $partStream.Write($buffer, 0, $buffer.Length) +} +else { + # and everything else is stringified + $buffer = $OutputEncoding.GetBytes("$content") + $partStream.Write($buffer, 0, $buffer.Length) +} + +# Close the part stream +$partStream.Close() \ No newline at end of file diff --git a/Types/OpenPackage/get_Astro.ps1 b/Types/OpenPackage/get_Astro.ps1 new file mode 100644 index 0000000..4ecdd0d --- /dev/null +++ b/Types/OpenPackage/get_Astro.ps1 @@ -0,0 +1,10 @@ +<# +.SYNOPSIS + Gets Open Package Astro Files +.DESCRIPTION + Gets Astro File Content in an Open Package +#> + +param() + +$this.GetContent($this.FileList -match '\.astro$') \ No newline at end of file diff --git a/Types/OpenPackage/get_CHANGELOG.md.ps1 b/Types/OpenPackage/get_CHANGELOG.md.ps1 new file mode 100644 index 0000000..a900612 --- /dev/null +++ b/Types/OpenPackage/get_CHANGELOG.md.ps1 @@ -0,0 +1,22 @@ +<# +.SYNOPSIS + Gets a package's changelog +.DESCRIPTION + Gets the content of any parts in the package named CHANGELOG.md +#> +[OutputType("text/markdown")] +param() + +# Get every part +foreach ($part in $this.GetParts()) { + # and ignore any part not named CHANGELOG + if ($part.Uri -notmatch '/CHANGELOG\.md$') { continue } + + if ($part.Reader) { + $part.Read() + } else { + $part + } +} + +# We are done. \ No newline at end of file diff --git a/Types/OpenPackage/get_Cache.ps1 b/Types/OpenPackage/get_Cache.ps1 new file mode 100644 index 0000000..d6354ef --- /dev/null +++ b/Types/OpenPackage/get_Cache.ps1 @@ -0,0 +1,17 @@ +<# +.SYNOPSIS + Gets cache +.DESCRIPTION + Gets an open package's cache. + + This is an ordered dictionary of data attached to the object, but not saved to disk. +#> +param() + +if (-not $this) { return } + +if (-not $this.'#Cache') { + Add-Member -InputObject $this NoteProperty '#Cache' ([Ordered]@{}) -Force +} + +return $this.'#Cache' \ No newline at end of file diff --git a/Types/OpenPackage/get_Category.ps1 b/Types/OpenPackage/get_Category.ps1 new file mode 100644 index 0000000..558cf93 --- /dev/null +++ b/Types/OpenPackage/get_Category.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Gets OpenPackage `Category` +.DESCRIPTION + Gets the OpenPackage `Category` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.category?wt.mc_id=MVP_321542 +#> +param() + +$this.PackageProperties.Category \ No newline at end of file diff --git a/Types/OpenPackage/get_ChocolateyInstall.ps1 b/Types/OpenPackage/get_ChocolateyInstall.ps1 new file mode 100644 index 0000000..5d5c232 --- /dev/null +++ b/Types/OpenPackage/get_ChocolateyInstall.ps1 @@ -0,0 +1,10 @@ +<# +.SYNOPSIS + Gets the Chocolatey Install Script +.DESCRIPTION + Gets the Chocolatey Install Script from an OpenPackage, if one is present. + + The Chocolatey install script must be located at /tools/chocolateyInstall.ps1 +#> +param() +$this.GetContent("/tools/chocolateyInstall.ps1") \ No newline at end of file diff --git a/Types/OpenPackage/get_Claude.md.ps1 b/Types/OpenPackage/get_Claude.md.ps1 new file mode 100644 index 0000000..ee3ca67 --- /dev/null +++ b/Types/OpenPackage/get_Claude.md.ps1 @@ -0,0 +1,19 @@ +<# +.SYNOPSIS + Gets a package's Claude Markdown +.DESCRIPTION + Gets any `claude.md` or `/.claude/*.md` files in an Open Package +.LINK + https://claude.com/blog/using-claude-md-files +#> +foreach ($part in $this.GetParts()) { + if ($part.Uri -match '/CLAUDE\.(?>md|markdown)$' -or + $part.Uri -match '/\.claude/.+?\.(?>md|markdown)$' + ) { + if ($part.Reader) { + $part.Read() + } else { + $part + } + } +} \ No newline at end of file diff --git a/Types/OpenPackage/get_CodeOfConduct.md.ps1 b/Types/OpenPackage/get_CodeOfConduct.md.ps1 new file mode 100644 index 0000000..8c8ba31 --- /dev/null +++ b/Types/OpenPackage/get_CodeOfConduct.md.ps1 @@ -0,0 +1,22 @@ +<# +.SYNOPSIS + Gets a package's code of conduct +.DESCRIPTION + Gets any parts in the package named Code_Of_Conduct.md +#> +[OutputType("text/markdown")] +param() + +# Get every part +foreach ($part in $this.GetParts()) { + # and ignore any part not named CODE_OF_CONDUCT + if ($part.Uri -notmatch '/CODE_OF_CONDUCT\.md$') { continue } + + if ($part.Reader) { + $part.Read() + } else { + $part + } +} + +# We are done. \ No newline at end of file diff --git a/Types/OpenPackage/get_Config.json.ps1 b/Types/OpenPackage/get_Config.json.ps1 new file mode 100644 index 0000000..7747444 --- /dev/null +++ b/Types/OpenPackage/get_Config.json.ps1 @@ -0,0 +1,21 @@ +<# +.SYNOPSIS + Gets a package's config json +.DESCRIPTION + Gets the objects stored in any config.json files in the package. +.NOTES + config.json files are used by several static site generators. +#> +[OutputType([PSObject])] +param() + +$partPattern = '[/\+_]config\.json$' + +foreach ($part in $this.GetParts()) { + if ($part.Uri -notmatch $partPattern) { continue } + if ($part.Reader) { + $part.Read() + } else { + $part + } +} diff --git a/Types/OpenPackage/get_Config.yaml.ps1 b/Types/OpenPackage/get_Config.yaml.ps1 new file mode 100644 index 0000000..b511b3e --- /dev/null +++ b/Types/OpenPackage/get_Config.yaml.ps1 @@ -0,0 +1,21 @@ +<# +.SYNOPSIS + Gets a package's config yaml +.DESCRIPTION + Gets the objects stored in any config.yaml files in the package. +.NOTES + config.yaml files are used by several static site generators. +#> +[OutputType([PSObject])] +param() + +$partPattern = '[/\+_]config\.ya?ml$' + +foreach ($part in $this.GetParts()) { + if ($part.Uri -notmatch $partPattern) { continue } + if ($part.Reader) { + $part.Read() + } else { + $part + } +} diff --git a/Types/OpenPackage/get_ContentStatus.ps1 b/Types/OpenPackage/get_ContentStatus.ps1 new file mode 100644 index 0000000..687270f --- /dev/null +++ b/Types/OpenPackage/get_ContentStatus.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Gets OpenPackage `ContentStatus` +.DESCRIPTION + Gets the OpenPackage `ContentStatus` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.contentstatus?wt.mc_id=MVP_321542 +#> +param() + +$this.PackageProperties.ContentStatus \ No newline at end of file diff --git a/Types/OpenPackage/get_ContentType.ps1 b/Types/OpenPackage/get_ContentType.ps1 new file mode 100644 index 0000000..f0fe842 --- /dev/null +++ b/Types/OpenPackage/get_ContentType.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Gets OpenPackage `ContentType` +.DESCRIPTION + Gets the OpenPackage `ContentType` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.contenttype?wt.mc_id=MVP_321542 +#> +param() + +$this.PackageProperties.ContentType -split ';' \ No newline at end of file diff --git a/Types/OpenPackage/get_Contributing.md.ps1 b/Types/OpenPackage/get_Contributing.md.ps1 new file mode 100644 index 0000000..100bd1d --- /dev/null +++ b/Types/OpenPackage/get_Contributing.md.ps1 @@ -0,0 +1,22 @@ +<# +.SYNOPSIS + Gets a package's contribution guide +.DESCRIPTION + Gets any parts in the package named Contributing.md +#> +[OutputType("text/markdown")] +param() + +# Get every part +foreach ($part in $this.GetParts()) { + # and ignore any part not named Contributing + if ($part.Uri -notmatch '/Contributing\.md$') { continue } + + if ($part.Reader) { + $part.Read() + } else { + $part + } +} + +# We are done. \ No newline at end of file diff --git a/Types/OpenPackage/get_Count.ps1 b/Types/OpenPackage/get_Count.ps1 new file mode 100644 index 0000000..a75a194 --- /dev/null +++ b/Types/OpenPackage/get_Count.ps1 @@ -0,0 +1,7 @@ +<# +.SYNOPSIS + Gets the package files count +.DESCRIPTION + Gets the number of files in a package. +#> +return @($this.GetParts()).Length \ No newline at end of file diff --git a/Types/OpenPackage/get_Created.ps1 b/Types/OpenPackage/get_Created.ps1 new file mode 100644 index 0000000..d91cb1e --- /dev/null +++ b/Types/OpenPackage/get_Created.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Gets OpenPackage creation time +.DESCRIPTION + Gets the OpenPackage `Created` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.created?wt.mc_id=MVP_321542 +#> +param() + +$this.PackageProperties.Created \ No newline at end of file diff --git a/Types/OpenPackage/get_Creator.ps1 b/Types/OpenPackage/get_Creator.ps1 new file mode 100644 index 0000000..ea596ce --- /dev/null +++ b/Types/OpenPackage/get_Creator.ps1 @@ -0,0 +1,38 @@ +<# +.SYNOPSIS + Gets OpenPackage `Creator` +.DESCRIPTION + Gets the OpenPackage `Creator` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.creator?wt.mc_id=MVP_321542 +#> +param() + +if ($this.PackageProperties.Creator) { + return $this.PackageProperties.Creator +} + +$moduleManifest = @($this.PowerShellManifest)[0] +if ($moduleManifest) { + $this.PackageProperties.Creator = $moduleManifest.Author + return $this.PackageProperties.Creator +} + +$packageJson = @($this.'Package.json')[0] +if ($packageJson -and $packageJson.author) { + $this.PackageProperties.Creator = + if ($packageJson.author -is [string]) { + $packageJson.author + } else { + $packageJson.author | ConvertTo-Json -Depth 3 + } + return $this.PackageProperties.Creator +} + +$nuSpec = @($this.nuSpec)[0] +if ($nuSpec -and $nuSpec.package.metadata.authors) { + $this.PackageProperties.Creator = + $nuSpec.package.metadata.authors + + return $this.PackageProperties.Creator +} \ No newline at end of file diff --git a/Types/OpenPackage/get_Description.ps1 b/Types/OpenPackage/get_Description.ps1 new file mode 100644 index 0000000..931a508 --- /dev/null +++ b/Types/OpenPackage/get_Description.ps1 @@ -0,0 +1 @@ +return $this.PackageProperties.Description \ No newline at end of file diff --git a/Types/OpenPackage/get_Dockerfile.ps1 b/Types/OpenPackage/get_Dockerfile.ps1 new file mode 100644 index 0000000..0cd0a85 --- /dev/null +++ b/Types/OpenPackage/get_Dockerfile.ps1 @@ -0,0 +1,16 @@ +<# +.SYNOPSIS + Gets a package's dockerfile +.DESCRIPTION + Gets the content of any `Dockerfile`s in the package. +#> +[OutputType([string])] +param() + +$partPattern = '[/\.]DockerFile$' + +foreach ($part in $this.GetParts()) { + if ($part.Uri -match $partPattern) { + $part.Read() + } +} \ No newline at end of file diff --git a/Types/OpenPackage/get_Eleventy.ps1 b/Types/OpenPackage/get_Eleventy.ps1 new file mode 100644 index 0000000..c8b2fd4 --- /dev/null +++ b/Types/OpenPackage/get_Eleventy.ps1 @@ -0,0 +1,26 @@ +<# +.SYNOPSIS + Gets a package's eleventy files +.DESCRIPTION + Gets the content of any elevent config files in the package. + + This includes any files named: + * `.eleventy.js` + * `eleventy.config.js` + * `eleventy.config.mjs` + * `eleventy.config.cjs` +.LINK + https://www.11ty.dev/docs/config/ +#> +param() + +$partPattern = '/(?>\.eleventy.js|elventy\.config\.[mc]?js$)' +foreach ($part in $this.GetParts()) { + if ($part.Uri -match $partPattern) { + if ($part.Reader) { + $part.Read() + } else { + $part + } + } +} diff --git a/Types/OpenPackage/get_Eponym.ps1 b/Types/OpenPackage/get_Eponym.ps1 new file mode 100644 index 0000000..bccfbe3 --- /dev/null +++ b/Types/OpenPackage/get_Eponym.ps1 @@ -0,0 +1,41 @@ +<# +.SYNOPSIS + Gets all Eponyms in a Package +.DESCRIPTION + Gets all Eponyms within an OpenPackage. + + Eponyms are files whose name matches their directory. + + For example: + + `/foo/foo.ps1` is an eponym + `/foo/foo.md` is an eponym + `/foo/bar.ps1` is not an eponym + + If a package has an an identifier, + any files whose name matches this identifier will be considered an eponym. +.NOTES + Multiple eponyms may exist for a given path. + + Anything before the first period `.` will be considered the name of the file. +#> +param() + +if (-not $this.GetParts) { return } +foreach ($part in $this.GetParts()) { + $partSegments = @($part.Uri -split '/+' -ne '') + if ($partSegments.Count -eq 1) { + if ($partSegments[0] -replace '\..+$' -eq $this.Identifier) { + $part + continue + } + } + if ($partSegments.Count -ge 2) { + if ($partSegments[-2] -eq + ($partSegments[-1] -replace '\..+$') + ) { + $part + continue + } + } +} diff --git a/Types/OpenPackage/get_FileContentType.ps1 b/Types/OpenPackage/get_FileContentType.ps1 new file mode 100644 index 0000000..2167528 --- /dev/null +++ b/Types/OpenPackage/get_FileContentType.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Gets file content types +.DESCRIPTION + Gets a table of all files in the package and their associated content types. +#> +$fileContentTypes = [Ordered]@{} +foreach ($part in @($this.GetParts())) { + $fileContentTypes[$part.Uri] = $part.ContentType +} +$fileContentTypes diff --git a/Types/OpenPackage/get_FileHash.ps1 b/Types/OpenPackage/get_FileHash.ps1 new file mode 100644 index 0000000..2328dbb --- /dev/null +++ b/Types/OpenPackage/get_FileHash.ps1 @@ -0,0 +1,15 @@ +<# +.SYNOPSIS + Gets the file hashes +.DESCRIPTION + Gets the file hashes of each part using any supported algorithm (default SHA256) +.NOTES + Supports any algorithm from Get-FileHash +.LINK + Get-FileHash +#> +param([string]$Algorithm = 'SHA256') + +foreach ($part in $this.GetParts()) { + $part.GetHash($Algorithm) +} diff --git a/Types/OpenPackage/get_FileList.ps1 b/Types/OpenPackage/get_FileList.ps1 new file mode 100644 index 0000000..3f56c05 --- /dev/null +++ b/Types/OpenPackage/get_FileList.ps1 @@ -0,0 +1,12 @@ +<# +.SYNOPSIS + Gets OpenPackage file list +.DESCRIPTION + Gets the list of files in an OpenPackage. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.package.getparts?wt.mc_id=MVP_321542 +#> +[OutputType([string[]])] +param() + +@($this.GetParts()).Uri -as [string[]] \ No newline at end of file diff --git a/Types/OpenPackage/get_FileSize.ps1 b/Types/OpenPackage/get_FileSize.ps1 new file mode 100644 index 0000000..078692e --- /dev/null +++ b/Types/OpenPackage/get_FileSize.ps1 @@ -0,0 +1,14 @@ +<# +.SYNOPSIS + Gets file content types +.DESCRIPTION + Gets a table of all files in the package and their associated content types. +#> +$fileLengths = [Ordered]@{} +foreach ($part in @($this.GetParts())) { + $partStream = $part.GetStream() + $fileLengths[$part.Uri] = $partStream.Length + $partStream.Close() + $partStream.Dispose() +} +$fileLengths diff --git a/Types/OpenPackage/get_Function.json.ps1 b/Types/OpenPackage/get_Function.json.ps1 new file mode 100644 index 0000000..ffea793 --- /dev/null +++ b/Types/OpenPackage/get_Function.json.ps1 @@ -0,0 +1,22 @@ +<# +.SYNOPSIS + Gets a package's `function.json` +.DESCRIPTION + Gets the content of any `function.json` files in the package. + + These files are used by Azure Functions +.LINK + https://github.com/Azure/azure-functions-host/wiki/function.json +#> +[OutputType([PSObject])] +param() + +foreach ($part in $this.GetParts()) { + if ($part.Uri -notmatch '/function\.json$') { continue } + + if ($part.Reader) { + $part.Read() + } else { + $part + } +} \ No newline at end of file diff --git a/Types/OpenPackage/get_Host.json.ps1 b/Types/OpenPackage/get_Host.json.ps1 new file mode 100644 index 0000000..e3dbc25 --- /dev/null +++ b/Types/OpenPackage/get_Host.json.ps1 @@ -0,0 +1,22 @@ +<# +.SYNOPSIS + Gets a package's `host.json` +.DESCRIPTION + Gets the content of any `host.json` files in the package. + + These files are used by Azure Functions +.LINK + https://github.com/Azure/azure-functions-host/wiki/host.json-(v2) +#> +[OutputType([PSObject])] +param() + +foreach ($part in $this.GetParts()) { + if ($part.Uri -notmatch '/host\.json$') { continue } + + if ($part.Reader) { + $part.Read() + } else { + $part + } +} \ No newline at end of file diff --git a/Types/OpenPackage/get_Identifier.ps1 b/Types/OpenPackage/get_Identifier.ps1 new file mode 100644 index 0000000..58bdc17 --- /dev/null +++ b/Types/OpenPackage/get_Identifier.ps1 @@ -0,0 +1 @@ +$this.PackageProperties.Identifier \ No newline at end of file diff --git a/Types/OpenPackage/get_ImageFileList.ps1 b/Types/OpenPackage/get_ImageFileList.ps1 new file mode 100644 index 0000000..a007a1d --- /dev/null +++ b/Types/OpenPackage/get_ImageFileList.ps1 @@ -0,0 +1,13 @@ +<# +.SYNOPSIS + Gets package image files +.DESCRIPTION + Gets the list of image files within a package. +#> +@(foreach ($part in $this.GetParts()) { + if ($part.ContentType -match 'image/' -or + $part.Uri -match '\.(?>a?png|jpe?g|gif|tiff?|svg|ico|bmp|exr)$' + ) { + $part.Uri + } +}) -as [string[]] \ No newline at end of file diff --git a/Types/OpenPackage/get_ImportMap.ps1 b/Types/OpenPackage/get_ImportMap.ps1 new file mode 100644 index 0000000..d8eec09 --- /dev/null +++ b/Types/OpenPackage/get_ImportMap.ps1 @@ -0,0 +1,12 @@ +<# +.SYNOPSIS + Gets a package's `importMap.json` +.DESCRIPTION + Gets the content of any `importMap.json` files in the package +#> +[OutputType([psobject])] +param() + + +$this.GetContent(@($this.FileList -match '\importMap\.json$')) + diff --git a/Types/OpenPackage/get_Includes.ps1 b/Types/OpenPackage/get_Includes.ps1 new file mode 100644 index 0000000..b9d6c47 --- /dev/null +++ b/Types/OpenPackage/get_Includes.ps1 @@ -0,0 +1,30 @@ +<# +.SYNOPSIS + Gets Open Package Includes +.DESCRIPTION + Gets any `_includes` files within an Open Package. + + If the include file type has a reader, will read the content. + + If the include file type does not have a reader, will include the part. +.NOTES + Returns all includes as a dictionary. +#> +if (-not $this.GetParts) { return } + +$includes = [Ordered]@{} + +foreach ($part in $this.GetParts()) { + if ($part.Uri -notmatch '/_includes/') { + continue + } + + $includeKey = $part.Uri -replace '.{0,}?/_includes/' + if ($part.Reader) { + $includes[$includeKey] = $part.Read() + } else { + $includes[$includeKey] = $part + } +} + +return $includes \ No newline at end of file diff --git a/Types/OpenPackage/get_Keywords.ps1 b/Types/OpenPackage/get_Keywords.ps1 new file mode 100644 index 0000000..5a27f9c --- /dev/null +++ b/Types/OpenPackage/get_Keywords.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Gets OpenPackage `Keywords` +.DESCRIPTION + Gets the OpenPackage `Keywords` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.keywords?wt.mc_id=MVP_321542 +#> +param() + +$this.PackageProperties.Keywords -split '[\s\r\n]+' \ No newline at end of file diff --git a/Types/OpenPackage/get_Language.ps1 b/Types/OpenPackage/get_Language.ps1 new file mode 100644 index 0000000..9b70582 --- /dev/null +++ b/Types/OpenPackage/get_Language.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Gets OpenPackage Language time +.DESCRIPTION + Gets the OpenPackage `Language` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.language?wt.mc_id=MVP_321542 +#> +param() + +$this.PackageProperties.Language \ No newline at end of file diff --git a/Types/OpenPackage/get_LanguagePercent.ps1 b/Types/OpenPackage/get_LanguagePercent.ps1 new file mode 100644 index 0000000..2381ba4 --- /dev/null +++ b/Types/OpenPackage/get_LanguagePercent.ps1 @@ -0,0 +1,112 @@ +<# +.SYNOPSIS + Gets the language percentages of a package +.DESCRIPTION + Gets the language percentages present in the package. +.NOTES + Definitions of what constitutes a language have been quite contentious. + + For the purposes of accurately identifying what lies within a package, we want a very broad definition. + + If you believe a language should be included, file an issue. + + If you believe any given file format is or is not a language, do not file an issue. +#> +$LanguagesByLength = [Ordered]@{} + +$totalLength = 0 +$fileSizes = $this.FileSize +foreach ($part in $this.GetParts()) { + $partLength = $fileSizes[$part.Uri] + + $recognizedLanguage = + switch -regex ($part.Uri) { + '\.3mf$' { '3MF'} + '\.astro' { 'Astro' } + '\.c$' { 'C' } + '\.cast$' { 'Asciiema' } + '\.clixml$' { 'Clixml'} + '\.cjs$' { 'Common JavaScript'} + '\.cpp$' { 'C++' } + '\.cs$' { 'C# '} + '\.csv$' { 'Comma Separated Values' } + '\.csh$' { 'CShell'} + '\.css$' { 'Cascading Stylesheets' } + '(?>/word/.+?\.xml|\.docx?)$' { 'Word '} + '\.dll$' { 'Binary' } + '\.exe$' { 'Binary' } + '\.gif$' { 'GIF' } + '\.go$' { 'Go Language' } + '\.h$' { 'C Header' } + '\.html?$' { 'Hypertext Markup Language' } + '\.java$' {'Java' } + '\.jpe?g$' { 'Joint Pictures Expert Group'} + '\.json$' {'JavaScript Object Notation' } + '\.jsonc$' {'Commented JavaScript Object Notation' } + '\.jsonl$' {'JavaScript Object Notation Lines' } + '\.js$' { 'Javascript'} + '\.jsx$' { 'JavaScript XML'} + '\.(?>md|mdx|markdown)$' { 'Markdown' } + '\.midi?$' { 'MIDI' } + '\.(?>jsm|mjs)$' { 'JavaScript Module'} + '\.mkv$' { 'Matroska Video'} + '\.mka$' { 'Matroska Audio'} + '\.mks$' { 'Matroska Subtitle'} + '\.mk3d$' { 'Matroska Stereoscopic Video'} + '\.mp3$' { 'MP3' } + '\.mp4$' { 'MP4' } + '\.nix$' { 'Nix' } + '\.oog$' { 'OOG' } + '\.pl$' { 'Perl' } + '\.png$' { 'Portable Network Graphics' } + '(?>/ppt/.+?\.xml|\.pptx?)$' { 'PowerPoint'} + '\.psm?1$' { 'PowerShell' } + '\.psd1$' {'PowerShell Data Language' } + '\.ps1xml$' { 'PowerShell Xml' } + '\.py$' { 'Python' } + '\.rs$' { 'Rust '} + '\.rss$' { 'RSS' } + '\.sh$' { 'BourneShell'} + '\.stl$' { 'STL'} + '\.svg$' { 'SVG' } + '\.tar$' { 'Tarfile' } + '(?>\.tar\.gz|\.tgz)$' { 'GZippedTarfile' } + '\.tsx?$' { 'TypeScript' } + '\.tsv$' { 'Tab Separated Values' } + '\.toml$' { 'Tom''s Obvious Minimal Language' } + '\.xhtml$' { 'XHTML' } + '(?>/xl/.+?\.xml|\.xlsx?)$' { 'Excel'} + '\.xsl$' { 'XSL' } + '\.xml$' { 'XML' } + '\.ya?ml$' { 'Yaml' } + '\.zip$' { 'Zip' } + '\.webm' { 'Web Movie' } + '\.weba' { 'Web Audio' } + '\.webp' { 'Web Photo' } + default { 'Unknown' } + } + + if (-not $recognizedLanguage) { + continue + } + + + if (-not $LanguagesByLength[$recognizedLanguage]) { + $LanguagesByLength[$recognizedLanguage] = 0 + } + + $LanguagesByLength[$recognizedLanguage]+=$partLength + + $totalLength += $partLength +} + + +$SortedByLength = [Ordered]@{} + +foreach ($keyValue in $languagesByLength.GetEnumerator() | + Sort-Object Value -Descending +) { + $SortedByLength[$keyValue.Key] = $keyValue.Value / $totalLength +} + +return $SortedByLength \ No newline at end of file diff --git a/Types/OpenPackage/get_LastModifiedBy.ps1 b/Types/OpenPackage/get_LastModifiedBy.ps1 new file mode 100644 index 0000000..8ff1250 --- /dev/null +++ b/Types/OpenPackage/get_LastModifiedBy.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Gets OpenPackage LastModifiedBy time +.DESCRIPTION + Gets the OpenPackage `LastModifiedBy` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.lastmodifiedby?wt.mc_id=MVP_321542 +#> +param() + +$this.PackageProperties.LastModifiedBy \ No newline at end of file diff --git a/Types/OpenPackage/get_LastPrinted.ps1 b/Types/OpenPackage/get_LastPrinted.ps1 new file mode 100644 index 0000000..85c4573 --- /dev/null +++ b/Types/OpenPackage/get_LastPrinted.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Gets OpenPackage LastPrinted time +.DESCRIPTION + Gets the OpenPackage `LastPrinted` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.lastprinted?wt.mc_id=MVP_321542 +#> +param() + +$this.PackageProperties.LastPrinted \ No newline at end of file diff --git a/Types/OpenPackage/get_Lexicon.ps1 b/Types/OpenPackage/get_Lexicon.ps1 new file mode 100644 index 0000000..dcf9bf0 --- /dev/null +++ b/Types/OpenPackage/get_Lexicon.ps1 @@ -0,0 +1,51 @@ +<# +.SYNOPSIS + Gets any lexicons +.DESCRIPTION + Gets all at protocol lexicons in the package + + A lexicon is defined by the presence of three keys: + + * `id` + * `lexicon` + * `defs + + The output will be a table mapping ids to contents. +#> +[OutputType([Ordered])] +param() + +$allLexicons = [Ordered]@{} + +# Get every part +foreach ($part in $this.GetParts()) { + # and ignore any part that is not json. + if ($part.Uri -notmatch '\.json$') { continue } + # Also ignore any package-lock files + if ($part.Uri -match 'package-lock') { continue } + + # If there is no reader, continue + if (-not $part.Reader) { continue } + + try { + # Try to read our data + $partData = $part.Read() + + # ignore any arrays + if ($partData -is [Object[]]) { continue } + + # If the data has a .lexicon, .id, and .defs + if ($partData.lexicon -and + $partData.id -and + $partData.defs + ) { + # store it in our lexicons table. + $allLexicons[$partData.id] = $partData + } + } catch { + Write-Warning "$($part.Uri) read error $($_)" + continue + } +} +# return all of the lexicons we found. +return $allLexicons \ No newline at end of file diff --git a/Types/OpenPackage/get_Manifest.json.ps1 b/Types/OpenPackage/get_Manifest.json.ps1 new file mode 100644 index 0000000..86a07a2 --- /dev/null +++ b/Types/OpenPackage/get_Manifest.json.ps1 @@ -0,0 +1,21 @@ +<# +.SYNOPSIS + Gets a package's `manifest.json` +.DESCRIPTION + Gets the content of any `manifest.json` files in the package +#> +param() + +# Get every part +foreach ($part in $this.GetParts()) { + # and ignore any part not named manifest.json + if ($part.Uri -notmatch '/manifest\.json$') { continue } + + if ($part.Reader) { + $part.Read() + } else { + $part + } +} + +# We are done. \ No newline at end of file diff --git a/Types/OpenPackage/get_Mcp.json.ps1 b/Types/OpenPackage/get_Mcp.json.ps1 new file mode 100644 index 0000000..d72cd62 --- /dev/null +++ b/Types/OpenPackage/get_Mcp.json.ps1 @@ -0,0 +1,36 @@ +<# +.SYNOPSIS + Gets a Package's mcp.json +.DESCRIPTION + Gets any mcp definitions in an Open Package + + Definitions can be in parts matching: + * `/mcp.json` + * `/claude_desktop_config.json` + * `/\.?mcp/server.json` +#> +param() + +$pattern = @( + "/mcp\.json" + "/claude_desktop_config\.json" + '/\.?mcp/server.json$' +) + +$pattern = "(?>$($pattern -join '|'))$" + +foreach ($part in $this.GetParts()) { + if ($part.Uri -notmatch $pattern) { + continue + } + + if ($part.Reader) { + try { + $part.Read() + } catch { + Write-Warning "Error reading $($part.Uri) : $_" + } + } else { + $part + } +} diff --git a/Types/OpenPackage/get_Modelfile.ps1 b/Types/OpenPackage/get_Modelfile.ps1 new file mode 100644 index 0000000..0985576 --- /dev/null +++ b/Types/OpenPackage/get_Modelfile.ps1 @@ -0,0 +1,18 @@ +<# +.SYNOPSIS + Gets any Modelfiles in a package +.DESCRIPTION + Gets any Ollama model files within a package +.LINK + https://docs.ollama.com/modelfile +#> +[OutputType([string])] +param() + +$partPattern = '[/\.]Modelfile$' + +foreach ($part in $this.GetParts()) { + if ($part.Uri -match $partPattern) { + $part.Read() + } +} \ No newline at end of file diff --git a/Types/OpenPackage/get_Modified.ps1 b/Types/OpenPackage/get_Modified.ps1 new file mode 100644 index 0000000..82d4157 --- /dev/null +++ b/Types/OpenPackage/get_Modified.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Gets OpenPackage modified time +.DESCRIPTION + Gets the OpenPackage `Modified` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.modified?wt.mc_id=MVP_321542 +#> +param() + +$this.PackageProperties.Modified \ No newline at end of file diff --git a/Types/OpenPackage/get_Nix.ps1 b/Types/OpenPackage/get_Nix.ps1 new file mode 100644 index 0000000..3162944 --- /dev/null +++ b/Types/OpenPackage/get_Nix.ps1 @@ -0,0 +1,10 @@ +<# +.SYNOPSIS + Gets a package's `*.nix` files +.DESCRIPTION + Gets the content of any `*.nix` files in the package. +#> +[OutputType([string])] +param() + +$this.GetContent(@($this.FileList) -match '\.nix$') diff --git a/Types/OpenPackage/get_Nuspec.ps1 b/Types/OpenPackage/get_Nuspec.ps1 new file mode 100644 index 0000000..87835aa --- /dev/null +++ b/Types/OpenPackage/get_Nuspec.ps1 @@ -0,0 +1,12 @@ +<# +.SYNOPSIS + Gets a package's nuspec +.DESCRIPTION + Gets the content of any `*.nuspec` files in the package +#> +[OutputType([xml])] +param() + +$this.GetContent( + $this.FileList -match '\.nuspec$' +) diff --git a/Types/OpenPackage/get_Package.json.ps1 b/Types/OpenPackage/get_Package.json.ps1 new file mode 100644 index 0000000..ad916fc --- /dev/null +++ b/Types/OpenPackage/get_Package.json.ps1 @@ -0,0 +1,22 @@ +<# +.SYNOPSIS + Gets a package's `package.json` +.DESCRIPTION + Gets the content of any `package.json` files in the package +#> +[OutputType([PSObject])] +param() + +# Get every part +foreach ($part in $this.GetParts()) { + # and ignore any part not named CHANGELOG + if ($part.Uri -notmatch '/package\.json$') { continue } + + if ($part.Reader) { + $part.Read() + } else { + $part + } +} + +# We are done. \ No newline at end of file diff --git a/Types/OpenPackage/get_Palette.ps1 b/Types/OpenPackage/get_Palette.ps1 new file mode 100644 index 0000000..da871b7 --- /dev/null +++ b/Types/OpenPackage/get_Palette.ps1 @@ -0,0 +1,18 @@ +<# +.SYNOPSIS + Gets an Open Package palette +.DESCRIPTION + Gets an Open Package's palette. +.NOTES + A palette is a relationship between a package and a stylesheet. +#> +param() + +if (-not $this.RelationshipExists -or + -not $this.GetRelationship) { + return +} + +if ($this.RelationshipExists('palette')) { + return $this.GetRelationship('palette').TargetUri +} \ No newline at end of file diff --git a/Types/OpenPackage/get_Parts.ps1 b/Types/OpenPackage/get_Parts.ps1 new file mode 100644 index 0000000..fa370a8 --- /dev/null +++ b/Types/OpenPackage/get_Parts.ps1 @@ -0,0 +1,13 @@ +<# +.SYNOPSIS + Gets Package Parts +.DESCRIPTION + Gets Open Package Parts. +.NOTES + This is a shorthand for the method `GetParts()` +#> +if (-not $this.GetParts) { + return +} + +@($this.GetParts()) diff --git a/Types/OpenPackage/get_PowerShellCommandAst.ps1 b/Types/OpenPackage/get_PowerShellCommandAst.ps1 new file mode 100644 index 0000000..6637f8b --- /dev/null +++ b/Types/OpenPackage/get_PowerShellCommandAst.ps1 @@ -0,0 +1,16 @@ +<# +.SYNOPSIS + Gets PowerShell Command References +.DESCRIPTION + Gets PowerShell Command Ast references within an Open Package. +#> +foreach ($content in $this.GetContent(@($this.FileList -match '.psm?1$'))) { + if (-not $content.Ast) { continue } + $content.Ast.FindAll({ + param($ast) + + $ast -is [Management.Automation.Language.CommandAst] + }, $true) | + Add-Member NoteProperty PartUri $content.PartUri -Force -PassThru | + Add-Member NoteProperty Package $content.Package -Force -PassThru +} \ No newline at end of file diff --git a/Types/OpenPackage/get_PowerShellFunctionAst.ps1 b/Types/OpenPackage/get_PowerShellFunctionAst.ps1 new file mode 100644 index 0000000..352e34d --- /dev/null +++ b/Types/OpenPackage/get_PowerShellFunctionAst.ps1 @@ -0,0 +1,16 @@ +<# +.SYNOPSIS + Gets PowerShell Function Defintions +.DESCRIPTION + Gets PowerShell Function Defintion Ast references within an Open Package. +#> +foreach ($content in $this.GetContent(@($this.FileList -match '.psm?1$'))) { + if (-not $content.Ast) { continue } + $content.Ast.FindAll({ + param($ast) + + $ast -is [Management.Automation.Language.FunctionDefinitionAst] + }, $true) | + Add-Member NoteProperty PartUri $content.PartUri -Force -PassThru | + Add-Member NoteProperty Package $content.Package -Force -PassThru +} \ No newline at end of file diff --git a/Types/OpenPackage/get_PowerShellManifest.ps1 b/Types/OpenPackage/get_PowerShellManifest.ps1 new file mode 100644 index 0000000..432b200 --- /dev/null +++ b/Types/OpenPackage/get_PowerShellManifest.ps1 @@ -0,0 +1,24 @@ +<# +.SYNOPSIS + Gets a package's PowerShell manifest files +.DESCRIPTION + Gets a package's PowerShell manifest files. + + These are any `*.psd1` files in the package that: + + * Are valid PowerShell data blocks + * Contain a ModuleVersion +#> +[OutputType([PSObject])] +param() + +foreach ($part in $this.GetParts()) { + if ($part.Uri -notmatch '\.psd1$') { continue } + try { + $psd1 = $part.Read() + if (-not $psd1.ModuleVersion) { continue } + $psd1 + } catch { + Write-Debug "Could not read $($part.Uri): $_" + } +} \ No newline at end of file diff --git a/Types/OpenPackage/get_PowerShellParameterAst.ps1 b/Types/OpenPackage/get_PowerShellParameterAst.ps1 new file mode 100644 index 0000000..b89b764 --- /dev/null +++ b/Types/OpenPackage/get_PowerShellParameterAst.ps1 @@ -0,0 +1,16 @@ +<# +.SYNOPSIS + Gets PowerShell Parameter Definitions +.DESCRIPTION + Gets all PowerShell ParameterAst references within an Open Package. +#> +foreach ($content in $this.GetContent(@($this.FileList -match '.psm?1$'))) { + if (-not $content.Ast) { continue } + $content.Ast.FindAll({ + param($ast) + + $ast -is [Management.Automation.Language.ParameterAst] + }, $true) | + Add-Member NoteProperty PartUri $content.PartUri -Force -PassThru | + Add-Member NoteProperty Package $content.Package -Force -PassThru +} \ No newline at end of file diff --git a/Types/OpenPackage/get_PowerShellTypeAst.ps1 b/Types/OpenPackage/get_PowerShellTypeAst.ps1 new file mode 100644 index 0000000..146cd37 --- /dev/null +++ b/Types/OpenPackage/get_PowerShellTypeAst.ps1 @@ -0,0 +1,16 @@ +<# +.SYNOPSIS + Gets PowerShell Type References +.DESCRIPTION + Gets PowerShell TypeExpressionAst references within an Open Package. +#> +foreach ($content in $this.GetContent(@($this.FileList -match '.psm?1$'))) { + if (-not $content.Ast) { continue } + $content.Ast.FindAll({ + param($ast) + + $ast -is [Management.Automation.Language.TypeExpressionAst] + }, $true) | + Add-Member NoteProperty PartUri $content.PartUri -Force -PassThru | + Add-Member NoteProperty Package $content.Package -Force -PassThru +} \ No newline at end of file diff --git a/Types/OpenPackage/get_PowerShellTypeDefinitionAst.ps1 b/Types/OpenPackage/get_PowerShellTypeDefinitionAst.ps1 new file mode 100644 index 0000000..fc358ef --- /dev/null +++ b/Types/OpenPackage/get_PowerShellTypeDefinitionAst.ps1 @@ -0,0 +1,16 @@ +<# +.SYNOPSIS + Gets PowerShell Type Defintions +.DESCRIPTION + Gets PowerShell Type Defintion Ast references within an Open Package. +#> +foreach ($content in $this.GetContent(@($this.FileList -match '.psm?1$'))) { + if (-not $content.Ast) { continue } + $content.Ast.FindAll({ + param($ast) + + $ast -is [Management.Automation.Language.TypeDefinitionAst] + }, $true) | + Add-Member NoteProperty PartUri $content.PartUri -Force -PassThru | + Add-Member NoteProperty Package $content.Package -Force -PassThru +} \ No newline at end of file diff --git a/Types/OpenPackage/get_PowerShellUniversal.ps1 b/Types/OpenPackage/get_PowerShellUniversal.ps1 new file mode 100644 index 0000000..66aa94e --- /dev/null +++ b/Types/OpenPackage/get_PowerShellUniversal.ps1 @@ -0,0 +1,12 @@ +<# +.SYNOPSIS + Gets PowerShell Universal Files in a package +.DESCRIPTION + Gets PowerShell Universal Files in an open package +#> + +foreach ($part in $this.GetParts()) { + if ($part.Uri -notmatch '/\.universal/') { continue } + if ($part.Uri -notmatch '\.ps1$') { continue } + $part +} \ No newline at end of file diff --git a/Types/OpenPackage/get_ProjectFile.ps1 b/Types/OpenPackage/get_ProjectFile.ps1 new file mode 100644 index 0000000..7c7a824 --- /dev/null +++ b/Types/OpenPackage/get_ProjectFile.ps1 @@ -0,0 +1,12 @@ +<# +.SYNOPSIS + Gets a package's project files +.DESCRIPTION + Gets the content of any `*.*proj` files in the package +#> +[OutputType([xml])] +param() + +$this.GetContent( + $this.FileList -match '\..+?proj$' +) \ No newline at end of file diff --git a/Types/OpenPackage/get_README.md.ps1 b/Types/OpenPackage/get_README.md.ps1 new file mode 100644 index 0000000..4a5fcdf --- /dev/null +++ b/Types/OpenPackage/get_README.md.ps1 @@ -0,0 +1,22 @@ +<# +.SYNOPSIS + Gets a package's README +.DESCRIPTION + Gets the content of any parts in the package named README.md +#> +[OutputType("text/markdown")] +param() + +# Get all Readme files +foreach ($content in $this.GetContent( + $this.FileList -match '/README\.md$' +)) { + # decorate them as text/markdown + if ($content.pstypenames -notcontains 'text/markdown') { + $content.pstypenames.insert(0, 'text/markdown') + } + # and return them. + $content +} + +return \ No newline at end of file diff --git a/Types/OpenPackage/get_Related.ps1 b/Types/OpenPackage/get_Related.ps1 new file mode 100644 index 0000000..60aa622 --- /dev/null +++ b/Types/OpenPackage/get_Related.ps1 @@ -0,0 +1,12 @@ +<# +.SYNOPSIS + Get Related Package information +.DESCRIPTION + Get Package Relationships +.NOTES + This is a shorthand for `.GetRelationships()` +#> +if (-not $this.GetRelationships) { + return +} +@($this.GetRelationships()) diff --git a/Types/OpenPackage/get_Reptile.ps1 b/Types/OpenPackage/get_Reptile.ps1 new file mode 100644 index 0000000..1f3f56f --- /dev/null +++ b/Types/OpenPackage/get_Reptile.ps1 @@ -0,0 +1,12 @@ +<# +.SYNOPSIS + Gets Open Package Reptiles +.DESCRIPTION + Gets Reptile files in Open Packages +.LINK + https://github.com/PoshWeb/Reptile +#> +foreach ($part in $this.GetParts()) { + if ($part.Uri -notmatch '\.reptile\.ps1$') { continue } + $part +} \ No newline at end of file diff --git a/Types/OpenPackage/get_Revision.ps1 b/Types/OpenPackage/get_Revision.ps1 new file mode 100644 index 0000000..7bd6a54 --- /dev/null +++ b/Types/OpenPackage/get_Revision.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Gets OpenPackage `Revision` +.DESCRIPTION + Gets the OpenPackage `Revision` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.revision?wt.mc_id=MVP_321542 +#> +param() + +$this.PackageProperties.Revision \ No newline at end of file diff --git a/Types/OpenPackage/get_Security.md.ps1 b/Types/OpenPackage/get_Security.md.ps1 new file mode 100644 index 0000000..1df9a1c --- /dev/null +++ b/Types/OpenPackage/get_Security.md.ps1 @@ -0,0 +1,22 @@ +<# +.SYNOPSIS + Gets a package's security notice +.DESCRIPTION + Gets any parts in the package named Security.md +#> +[OutputType("text/markdown")] +param() + +# Get every part +foreach ($part in $this.GetParts()) { + # and ignore any part not named SECURITY + if ($part.Uri -notmatch '/SECURITY\.md$') { continue } + + if ($part.Reader) { + $part.Read() + } else { + $part + } +} + +# We are done. \ No newline at end of file diff --git a/Types/OpenPackage/get_ServiceWorker.js.ps1 b/Types/OpenPackage/get_ServiceWorker.js.ps1 new file mode 100644 index 0000000..b3ee1fa --- /dev/null +++ b/Types/OpenPackage/get_ServiceWorker.js.ps1 @@ -0,0 +1,23 @@ +<# +.SYNOPSIS + Gets any service workers in a package +.DESCRIPTION + Gets any clearly named service workers in a package. + + Will find any files named `sw.js` or `ServiceWorker.js` +#> +param() + +$pattern = '/(?>sw|ServiceWorker).js$' + +foreach ($part in $this.GetParts()) { + if ($part.Uri -match $pattern) { + if ($part.Reader) { + $part.Read() + } else { + $part + } + } +} + +# We are done. \ No newline at end of file diff --git a/Types/OpenPackage/get_Subject.ps1 b/Types/OpenPackage/get_Subject.ps1 new file mode 100644 index 0000000..3885796 --- /dev/null +++ b/Types/OpenPackage/get_Subject.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Gets OpenPackage `Subject` +.DESCRIPTION + Gets the OpenPackage `Subject` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.subject?wt.mc_id=MVP_321542 +#> +param() + +$this.PackageProperties.Subject \ No newline at end of file diff --git a/Types/OpenPackage/get_Template.json.ps1 b/Types/OpenPackage/get_Template.json.ps1 new file mode 100644 index 0000000..a6fa2f3 --- /dev/null +++ b/Types/OpenPackage/get_Template.json.ps1 @@ -0,0 +1,18 @@ +<# +.SYNOPSIS + Gets a package's `template.json` +.DESCRIPTION + Gets the content of any `template.json` files in the package +#> +[OutputType([PSObject])] +param() + +foreach ($part in $this.GetParts()) { + if ($part.Uri -notmatch '/template\.json$') { continue } + + if ($part.Reader) { + $part.Read() + } else { + $part + } +} \ No newline at end of file diff --git a/Types/OpenPackage/get_Title.ps1 b/Types/OpenPackage/get_Title.ps1 new file mode 100644 index 0000000..eff3b41 --- /dev/null +++ b/Types/OpenPackage/get_Title.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Gets OpenPackage `Title` +.DESCRIPTION + Gets the OpenPackage `Title` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.title?wt.mc_id=MVP_321542 +#> +param() + +$this.PackageProperties.Title \ No newline at end of file diff --git a/Types/OpenPackage/get_TypeScriptConfig.json.ps1 b/Types/OpenPackage/get_TypeScriptConfig.json.ps1 new file mode 100644 index 0000000..61ffd14 --- /dev/null +++ b/Types/OpenPackage/get_TypeScriptConfig.json.ps1 @@ -0,0 +1,22 @@ +<# +.SYNOPSIS + Gets a package's `tsconfig.json` +.DESCRIPTION + Gets the content of any TypeScript configuration `tsconfig.json` files in the package +#> +[OutputType([psobject])] +param() + +# Get every part +foreach ($part in $this.GetParts()) { + # and ignore any part not named manifest.json + if ($part.Uri -notmatch '/tsconfig\.json$') { continue } + + if ($part.Reader) { + $part.Read() + } else { + $part + } +} + +# We are done. \ No newline at end of file diff --git a/Types/OpenPackage/get_Underscore.ps1 b/Types/OpenPackage/get_Underscore.ps1 new file mode 100644 index 0000000..f888de9 --- /dev/null +++ b/Types/OpenPackage/get_Underscore.ps1 @@ -0,0 +1,14 @@ +<# +.SYNOPSIS + Gets package underscore files +.DESCRIPTION + Gets underscore files defined in a package. + + These are any files that contain an underscore `_` in their path. + + This returns a series of dictionaries containing the contents of the package +#> +param() + + +return $this.GetTree("/_") diff --git a/Types/OpenPackage/get_Version.ps1 b/Types/OpenPackage/get_Version.ps1 new file mode 100644 index 0000000..ae2bd44 --- /dev/null +++ b/Types/OpenPackage/get_Version.ps1 @@ -0,0 +1,39 @@ +<# +.SYNOPSIS + Gets OpenPackage `Version` +.DESCRIPTION + Gets the OpenPackage `Version` property. + + If this has not been explicitly set, looks for potential version information in: + + * PowerShell Manifests + * package.json + * nuspec files +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.version?wt.mc_id=MVP_321542 +#> +param() + +if ($this.PackageProperties.Version) { + return $this.PackageProperties.Version +} + +$moduleManifest = @($this.PowerShellManifest)[0] +if ($moduleManifest) { + $this.PackageProperties.Version = $moduleManifest.ModuleVersion + return $this.PackageProperties.Version +} + +$packageJson = @($this.'Package.json')[0] +if ($packageJson -and $packageJson.version) { + $this.PackageProperties.Version = $packageJson.version + return $this.PackageProperties.Version +} + +$nuSpec = @($this.nuSpec)[0] +if ($nuSpec -and $nuSpec.package.metadata.version) { + $this.PackageProperties.Version = + $nuSpec.package.metadata.version + + return $this.PackageProperties.Version +} \ No newline at end of file diff --git a/Types/OpenPackage/get_VsixManifest.ps1 b/Types/OpenPackage/get_VsixManifest.ps1 new file mode 100644 index 0000000..b36c85f --- /dev/null +++ b/Types/OpenPackage/get_VsixManifest.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Gets VsixManfiests from a package +.DESCRIPTION + Gets any Visual Studio Extension Manifests (*.vsixManifest) files in a package. +#> +$partPattern = '\.vsixManifest$' + +$this.GetContent(@( + $this.FileList -match $partPattern +)) diff --git a/Types/OpenPackage/get_XRPC.ps1 b/Types/OpenPackage/get_XRPC.ps1 new file mode 100644 index 0000000..bac0b3a --- /dev/null +++ b/Types/OpenPackage/get_XRPC.ps1 @@ -0,0 +1,16 @@ +<# +.SYNOPSIS + Gets package xrpc +.DESCRIPTION + Gets any xrpc parts within the package. +#> +if (-not $this.GetParts) { return } +foreach ($part in $this.GetParts()) { + if ( + $part.Uri -notmatch '/xrpc' -or + $part.Uri -notlike '*.*.*' + ) { + continue + } + $part +} \ No newline at end of file diff --git a/Types/OpenPackage/set_Category.ps1 b/Types/OpenPackage/set_Category.ps1 new file mode 100644 index 0000000..25a56c6 --- /dev/null +++ b/Types/OpenPackage/set_Category.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Sets OpenPackage `Category` +.DESCRIPTION + Sets the OpenPackage `Category` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.category?wt.mc_id=MVP_321542 +#> +param([string]$Category) + +$this.PackageProperties.Category = $Category \ No newline at end of file diff --git a/Types/OpenPackage/set_ContentStatus.ps1 b/Types/OpenPackage/set_ContentStatus.ps1 new file mode 100644 index 0000000..01cce10 --- /dev/null +++ b/Types/OpenPackage/set_ContentStatus.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Sets OpenPackage `ContentStatus` +.DESCRIPTION + Sets the OpenPackage `ContentStatus` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.contentstatus?wt.mc_id=MVP_321542 +#> +param([string]$ContentStatus) + +$this.PackageProperties.ContentStatus = $ContentStatus \ No newline at end of file diff --git a/Types/OpenPackage/set_ContentType.ps1 b/Types/OpenPackage/set_ContentType.ps1 new file mode 100644 index 0000000..cdd6189 --- /dev/null +++ b/Types/OpenPackage/set_ContentType.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Sets OpenPackage `ContentType` +.DESCRIPTION + Sets the OpenPackage `ContentType` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.contenttype?wt.mc_id=MVP_321542 +#> +param([string]$ContentType) + +$this.PackageProperties.ContentType = $ContentType \ No newline at end of file diff --git a/Types/OpenPackage/set_Created.ps1 b/Types/OpenPackage/set_Created.ps1 new file mode 100644 index 0000000..4f6aaf4 --- /dev/null +++ b/Types/OpenPackage/set_Created.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Sets OpenPackage `Created` +.DESCRIPTION + Sets the OpenPackage `Created` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.created?wt.mc_id=MVP_321542 +#> +param([DateTime]$Created) + +$this.PackageProperties.Created = $Created \ No newline at end of file diff --git a/Types/OpenPackage/set_Creator.ps1 b/Types/OpenPackage/set_Creator.ps1 new file mode 100644 index 0000000..cb73414 --- /dev/null +++ b/Types/OpenPackage/set_Creator.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Sets OpenPackage `Creator` +.DESCRIPTION + Sets the OpenPackage `Creator` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.creator?wt.mc_id=MVP_321542 +#> +param([string]$Creator) + +$this.PackageProperties.Creator = $Creator \ No newline at end of file diff --git a/Types/OpenPackage/set_Description.ps1 b/Types/OpenPackage/set_Description.ps1 new file mode 100644 index 0000000..292e701 --- /dev/null +++ b/Types/OpenPackage/set_Description.ps1 @@ -0,0 +1 @@ +$this.PackageProperties.Description = $args -join [Environment]::NewLine \ No newline at end of file diff --git a/Types/OpenPackage/set_Identifier.ps1 b/Types/OpenPackage/set_Identifier.ps1 new file mode 100644 index 0000000..2b8a7af --- /dev/null +++ b/Types/OpenPackage/set_Identifier.ps1 @@ -0,0 +1 @@ +$this.PackageProperties.Identifier = $args -join ' ' \ No newline at end of file diff --git a/Types/OpenPackage/set_Keywords.ps1 b/Types/OpenPackage/set_Keywords.ps1 new file mode 100644 index 0000000..c62fe2d --- /dev/null +++ b/Types/OpenPackage/set_Keywords.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Sets OpenPackage `Keywords` +.DESCRIPTION + Sets the OpenPackage `Keywords` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.keywords?wt.mc_id=MVP_321542 +#> +param() + +$this.PackageProperties.Keywords = $args -join ' ' \ No newline at end of file diff --git a/Types/OpenPackage/set_Language.ps1 b/Types/OpenPackage/set_Language.ps1 new file mode 100644 index 0000000..e87a7e8 --- /dev/null +++ b/Types/OpenPackage/set_Language.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Sets OpenPackage `Language` +.DESCRIPTION + Sets the OpenPackage `Language` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.language?wt.mc_id=MVP_321542 +#> +param([string]$Language) + +$this.PackageProperties.Language = $Language \ No newline at end of file diff --git a/Types/OpenPackage/set_LastModifiedBy.ps1 b/Types/OpenPackage/set_LastModifiedBy.ps1 new file mode 100644 index 0000000..d8b9339 --- /dev/null +++ b/Types/OpenPackage/set_LastModifiedBy.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Sets OpenPackage `LastModifiedBy` +.DESCRIPTION + Sets the OpenPackage `LastModifiedBy` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.Lastmodifiedby?wt.mc_id=MVP_321542 +#> +param([string]$LastModifiedBy) + +$this.PackageProperties.LastModifiedBy = $LastModifiedBy \ No newline at end of file diff --git a/Types/OpenPackage/set_LastPrinted.ps1 b/Types/OpenPackage/set_LastPrinted.ps1 new file mode 100644 index 0000000..e743920 --- /dev/null +++ b/Types/OpenPackage/set_LastPrinted.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Sets OpenPackage `LastPrinted` +.DESCRIPTION + Sets the OpenPackage `LastPrinted` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.lastprinted?wt.mc_id=MVP_321542 +#> +param([DateTime]$LastPrinted) + +$this.PackageProperties.LastPrinted = $LastPrinted \ No newline at end of file diff --git a/Types/OpenPackage/set_Modified.ps1 b/Types/OpenPackage/set_Modified.ps1 new file mode 100644 index 0000000..92908d8 --- /dev/null +++ b/Types/OpenPackage/set_Modified.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Sets OpenPackage `Modified` +.DESCRIPTION + Sets the OpenPackage `Modified` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.modified?wt.mc_id=MVP_321542 +#> +param([DateTime]$Modified) + +$this.PackageProperties.Modifiedd = $Modified \ No newline at end of file diff --git a/Types/OpenPackage/set_Palette.ps1 b/Types/OpenPackage/set_Palette.ps1 new file mode 100644 index 0000000..96d2fe9 --- /dev/null +++ b/Types/OpenPackage/set_Palette.ps1 @@ -0,0 +1,23 @@ +<# +.SYNOPSIS + Sets an Open Package palette +.DESCRIPTION + Sets an Open Package's palette. +.NOTES + A palette is a relationship between a package and a stylesheet. +#> +param( +[Parameter(Mandatory)] +[ValidatePattern('.css$')] +[uri]$Palette +) +if (-not $this.RelationshipExists) { + return +} +if ($this.RelationshipExists('palette')) { + $this.DeleteRelationship('palette') + $this.CreateRelationship($Palette, 'external', 'stylesheet', 'palette') +} else { + $this.CreateRelationship($Palette, 'external', 'stylesheet', 'palette') +} + diff --git a/Types/OpenPackage/set_Revision.ps1 b/Types/OpenPackage/set_Revision.ps1 new file mode 100644 index 0000000..1aede6c --- /dev/null +++ b/Types/OpenPackage/set_Revision.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Sets OpenPackage `Revision` +.DESCRIPTION + Sets the OpenPackage `Revision` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.revision?wt.mc_id=MVP_321542 +#> +param([string]$Revision) + +$this.PackageProperties.Revision = $Revision \ No newline at end of file diff --git a/Types/OpenPackage/set_Subject.ps1 b/Types/OpenPackage/set_Subject.ps1 new file mode 100644 index 0000000..3f35bb1 --- /dev/null +++ b/Types/OpenPackage/set_Subject.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Sets OpenPackage `Subject` +.DESCRIPTION + Sets the OpenPackage `Subject` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.subject?wt.mc_id=MVP_321542 +#> +param([string]$Subject) + +$this.PackageProperties.Subject = $Subject \ No newline at end of file diff --git a/Types/OpenPackage/set_Title.ps1 b/Types/OpenPackage/set_Title.ps1 new file mode 100644 index 0000000..a6ee923 --- /dev/null +++ b/Types/OpenPackage/set_Title.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Sets OpenPackage `Title` +.DESCRIPTION + Sets the OpenPackage `Title` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.title?wt.mc_id=MVP_321542 +#> +param([string]$Title) + +$this.PackageProperties.Title = $Title \ No newline at end of file diff --git a/Types/OpenPackage/set_Version.ps1 b/Types/OpenPackage/set_Version.ps1 new file mode 100644 index 0000000..2e642df --- /dev/null +++ b/Types/OpenPackage/set_Version.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Sets OpenPackage `Version` +.DESCRIPTION + Sets the OpenPackage `Version` property. +.LINK + https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties.version?wt.mc_id=MVP_321542 +#> +param([string]$Version) + +$this.PackageProperties.Version = $Version \ No newline at end of file diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..25c81c0 --- /dev/null +++ b/action.yml @@ -0,0 +1,392 @@ + +name: OpenPackage +description: Open Package Action - Open anything as a package +inputs: + Run: + required: false + description: | + A PowerShell Script that uses OP. + Any files outputted from the script will be added to the repository. + If those files have a .Message attached to them, they will be committed with that message. + SkipScriptFile: + required: false + description: 'If set, will not process any files named *.OP.ps1' + InstallModule: + required: false + description: A list of modules to be installed from the PowerShell gallery before scripts run. + ActionScript: + required: false + description: The name of one or more scripts to run, from this action's path. + GitHubToken: + required: false + default: '{{ secrets.GITHUB_TOKEN }}' + description: The github token to use for requests. + UserEmail: + required: false + description: | + The user email associated with a git commit. + If this is not provided, it will be set to the id+username@users.noreply.github.com. + UserName: + required: false + description: | + The user name associated with a git commit. + If this is not provided, it will be set to the $env:GITHUB_ACTOR + Push: + required: false + description: | + If set, will push any changes made to the repository. + (they will still be committed unless `-NoCommit` is passed) +branding: + icon: code + color: blue +runs: + using: composite + steps: + - name: OPAction + id: OPAction + shell: pwsh + env: + ActionScript: ${{inputs.ActionScript}} + Push: ${{inputs.Push}} + UserName: ${{inputs.UserName}} + GitHubToken: ${{inputs.GitHubToken}} + InstallModule: ${{inputs.InstallModule}} + Run: ${{inputs.Run}} + UserEmail: ${{inputs.UserEmail}} + SkipScriptFile: ${{inputs.SkipScriptFile}} + run: | + $Parameters = @{} + $Parameters.Run = ${env:Run} + $Parameters.SkipScriptFile = ${env:SkipScriptFile} + $Parameters.SkipScriptFile = $parameters.SkipScriptFile -match 'true'; + $Parameters.InstallModule = ${env:InstallModule} + $Parameters.InstallModule = $parameters.InstallModule -split ';' -replace '^[''"]' -replace '[''"]$' + $Parameters.ActionScript = ${env:ActionScript} + $Parameters.ActionScript = $parameters.ActionScript -split ';' -replace '^[''"]' -replace '[''"]$' + $Parameters.GitHubToken = ${env:GitHubToken} + $Parameters.UserEmail = ${env:UserEmail} + $Parameters.UserName = ${env:UserName} + $Parameters.Push = ${env:Push} + $Parameters.Push = $parameters.Push -match 'true'; + foreach ($k in @($parameters.Keys)) { + if ([String]::IsNullOrEmpty($parameters[$k])) { + $parameters.Remove($k) + } + } + Write-Host "::debug:: OPAction $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" + & {<# + .Synopsis + GitHub Action for OP + .Description + GitHub Action for OP. This will: + + * Import OP + * If `-Run` is provided, run that script + * Otherwise, unless `-SkipScriptFile` is passed, run all *.OP.ps1 files beneath the workflow directory + * If any `-ActionScript` was provided, run scripts from the action path that match a wildcard pattern. + + If you will be making changes using the GitHubAPI, you should provide a -GitHubToken + If none is provided, and ENV:GITHUB_TOKEN is set, this will be used instead. + Any files changed can be outputted by the script, and those changes can be checked back into the repo. + Make sure to use the "persistCredentials" option with checkout. + #> + + param( + # A PowerShell Script that uses OP. + # Any files outputted from the script will be added to the repository. + # If those files have a .Message attached to them, they will be committed with that message. + [string] + $Run, + + # If set, will not process any files named *.OP.ps1 + [switch] + $SkipScriptFile, + + # A list of modules to be installed from the PowerShell gallery before scripts run. + [string[]] + $InstallModule, + + # The name of one or more scripts to run, from this action's path. + [string[]] + $ActionScript, + + # The github token to use for requests. + [string] + $GitHubToken = '{{ secrets.GITHUB_TOKEN }}', + + # The user email associated with a git commit. + # If this is not provided, it will be set to the id+username@users.noreply.github.com. + [string] + $UserEmail, + + # The user name associated with a git commit. + # If this is not provided, it will be set to the $env:GITHUB_ACTOR + [string] + $UserName, + + # If set, will push any changes made to the repository. + # (they will still be committed unless `-NoCommit` is passed) + [switch] + $Push + ) + + $ErrorActionPreference = 'continue' + "::group::Parameters" | Out-Host + [PSCustomObject]$PSBoundParameters | Format-List | Out-Host + "::endgroup::" | Out-Host + + $gitHubEventJson = [IO.File]::ReadAllText($env:GITHUB_EVENT_PATH) + $gitHubEvent = + if ($env:GITHUB_EVENT_PATH) { + $gitHubEventJson | ConvertFrom-Json + } else { $null } + "::group::Parameters" | Out-Host + $gitHubEvent | Format-List | Out-Host + "::endgroup::" | Out-Host + + + $anyFilesChanged = $false + $ActionModuleName = 'OP' + $actorInfo = $null + + if ($GitHubToken) { + $env:GH_TOKEN = $GitHubToken + } + + + $checkDetached = git symbolic-ref -q HEAD + if ($LASTEXITCODE) { + "::warning::On detached head, skipping action" | Out-Host + exit 0 + } + + function InstallActionModule { + param([string]$ModuleToInstall) + $moduleInWorkspace = Get-ChildItem -Path $env:GITHUB_WORKSPACE -Recurse -File | + Where-Object Name -eq "$($moduleToInstall).psd1" | + Where-Object { + $(Get-Content $_.FullName -Raw) -match 'ModuleVersion' + } + if (-not $moduleInWorkspace) { + $availableModules = Get-Module -ListAvailable + if ($availableModules.Name -notcontains $moduleToInstall) { + Install-Module $moduleToInstall -Scope CurrentUser -Force -AcceptLicense -AllowClobber + } + Import-Module $moduleToInstall -Force -PassThru | Out-Host + } else { + Import-Module $moduleInWorkspace.FullName -Force -PassThru | Out-Host + } + } + function ImportActionModule { + #region -InstallModule + if ($InstallModule) { + "::group::Installing Modules" | Out-Host + foreach ($moduleToInstall in $InstallModule) { + InstallActionModule -ModuleToInstall $moduleToInstall + } + "::endgroup::" | Out-Host + } + #endregion -InstallModule + + if ($env:GITHUB_ACTION_PATH) { + $LocalModulePath = Join-Path $env:GITHUB_ACTION_PATH "$ActionModuleName.psd1" + if (Test-path $LocalModulePath) { + Import-Module $LocalModulePath -Force -PassThru | Out-String + } else { + throw "Module '$ActionModuleName' not found" + } + } elseif (-not (Get-Module $ActionModuleName)) { + throw "Module '$ActionModuleName' not found" + } + + "::notice title=ModuleLoaded::$ActionModuleName Loaded from Path - $($LocalModulePath)" | Out-Host + if ($env:GITHUB_STEP_SUMMARY) { + "# $($ActionModuleName)" | + Out-File -Append -FilePath $env:GITHUB_STEP_SUMMARY + } + } + function InitializeAction { + #region Custom + #endregion Custom + + # Configure git based on the $env:GITHUB_ACTOR + if (-not $UserName) { $UserName = $env:GITHUB_ACTOR } + if (-not $actorID) { $actorID = $env:GITHUB_ACTOR_ID } + if (-not $UserEmail) { $UserEmail = "$actorID+$UserName@users.noreply.github.com" } + git config --global user.email $UserEmail + git config --global user.name $UserName + + # Pull down any changes + git pull | Out-Host + } + + function InvokeActionModule { + $myScriptStart = [DateTime]::Now + $myScript = $ExecutionContext.SessionState.PSVariable.Get("Run").Value + if ($myScript) { + $myScript > ./_run.ps1 + . ./_run.ps1 | + . ProcessOutput | + Out-Host + Remove-Item ./_run.ps1 + return + } + $myScriptTook = [Datetime]::Now - $myScriptStart + $MyScriptFilesStart = [DateTime]::Now + + $myScriptList = @() + $shouldSkip = $ExecutionContext.SessionState.PSVariable.Get("SkipScriptFile").Value + if ($shouldSkip) { + return + } + $scriptFiles = @( + Get-ChildItem -Recurse -Path $env:GITHUB_WORKSPACE | + Where-Object Name -Match "\.$($ActionModuleName)\.ps1$" + if ($ActionScript) { + if ($ActionScript -match '^\s{0,}/' -and $ActionScript -match '/\s{0,}$') { + $ActionScriptPattern = $ActionScript.Trim('/').Trim() -as [regex] + if ($ActionScriptPattern) { + $ActionScriptPattern = [regex]::new($ActionScript.Trim('/').Trim(), 'IgnoreCase,IgnorePatternWhitespace', [timespan]::FromSeconds(0.5)) + Get-ChildItem -Recurse -Path $env:GITHUB_ACTION_PATH | + Where-Object { $_.Name -Match "\.$($ActionModuleName)\.ps1$" -and $_.FullName -match $ActionScriptPattern } + } + } else { + Get-ChildItem -Recurse -Path $env:GITHUB_ACTION_PATH | + Where-Object Name -Match "\.$($ActionModuleName)\.ps1$" | + Where-Object FullName -Like $ActionScript + } + } + ) | Select-Object -Unique + $scriptFiles | + ForEach-Object -Begin { + if ($env:GITHUB_STEP_SUMMARY) { + "## $ActionModuleName Scripts" | + Out-File -Append -FilePath $env:GITHUB_STEP_SUMMARY + } + } -Process { + $myScriptList += $_.FullName.Replace($env:GITHUB_WORKSPACE, '').TrimStart('/') + $myScriptCount++ + $scriptFile = $_ + if ($env:GITHUB_STEP_SUMMARY) { + "### $($scriptFile.Fullname -replace [Regex]::Escape($env:GITHUB_WORKSPACE))" | + Out-File -Append -FilePath $env:GITHUB_STEP_SUMMARY + } + $scriptCmd = $ExecutionContext.SessionState.InvokeCommand.GetCommand($scriptFile.FullName, 'ExternalScript') + foreach ($requiredModule in $CommandInfo.ScriptBlock.Ast.ScriptRequirements.RequiredModules) { + if ($requiredModule.Name -and + (-not $requiredModule.MaximumVersion) -and + (-not $requiredModule.RequiredVersion) + ) { + InstallActionModule $requiredModule.Name + } + } + Push-Location $scriptFile.Directory.Fullname + $scriptFileOutputs = . $scriptCmd + $scriptFileOutputs | + . ProcessOutput | + Out-Host + Pop-Location + } + + $MyScriptFilesTook = [Datetime]::Now - $MyScriptFilesStart + $SummaryOfMyScripts = "$myScriptCount $ActionModuleName scripts took $($MyScriptFilesTook.TotalSeconds) seconds" + $SummaryOfMyScripts | + Out-Host + if ($env:GITHUB_STEP_SUMMARY) { + $SummaryOfMyScripts | + Out-File -Append -FilePath $env:GITHUB_STEP_SUMMARY + } + #region Custom + #endregion Custom + } + + function OutError { + $anyRuntimeExceptions = $false + foreach ($err in $error) { + $errParts = @( + "::error " + @( + if ($err.InvocationInfo.ScriptName) { + "file=$($err.InvocationInfo.ScriptName)" + } + if ($err.InvocationInfo.ScriptLineNumber -ge 1) { + "line=$($err.InvocationInfo.ScriptLineNumber)" + if ($err.InvocationInfo.OffsetInLine -ge 1) { + "col=$($err.InvocationInfo.OffsetInLine)" + } + } + if ($err.CategoryInfo.Activity) { + "title=$($err.CategoryInfo.Activity)" + } + ) -join ',' + "::" + $err.Exception.Message + if ($err.CategoryInfo.Category -eq 'OperationStopped' -and + $err.CategoryInfo.Reason -eq 'RuntimeException') { + $anyRuntimeExceptions = $true + } + ) -join '' + $errParts | Out-Host + if ($anyRuntimeExceptions) { + exit 1 + } + } + } + + function PushActionOutput { + if ($anyFilesChanged) { + "::notice::$($anyFilesChanged) Files Changed" | Out-Host + } + if ($anyFilesChanged) { + $checkDetached = git symbolic-ref -q HEAD + if (-not $LASTEXITCODE -and $Push -and -not $noCommit) { + if ($anyFilesChanged) { + "::notice::Pushing Changes" | Out-Host + git push + } + "Git Push Output: $($gitPushed | Out-String)" + } else { + "::notice::Not pushing changes (on detached head)" | Out-Host + $LASTEXITCODE = 0 + exit 0 + } + } + } + + filter ProcessOutput { + $out = $_ + $outItem = Get-Item -Path $out -ErrorAction Ignore + if (-not $outItem -and $out -is [string]) { + $out | Out-Host + if ($env:GITHUB_STEP_SUMMARY) { + "> $out" | Out-File -Append -FilePath $env:GITHUB_STEP_SUMMARY + } + return + } + $fullName, $shouldCommit = + if ($out -is [IO.FileInfo]) { + $out.FullName, (git status $out.Fullname -s) + } elseif ($outItem) { + $outItem.FullName, (git status $outItem.Fullname -s) + } + if ($shouldCommit -and -not $NoCommit) { + "$fullName has changed, and should be committed" | Out-Host + if ($Push) { + git add $fullName + if ($out.Message) { + git commit -m "$($out.Message)" | Out-Host + } elseif ($out.CommitMessage) { + git commit -m "$($out.CommitMessage)" | Out-Host + } + } + $anyFilesChanged = $true + } + $out + } + + . ImportActionModule + . InitializeAction + . InvokeActionModule + . PushActionOutput + . OutError} @Parameters + diff --git a/code_of_conduct.md b/code_of_conduct.md new file mode 100644 index 0000000..fdf09f3 --- /dev/null +++ b/code_of_conduct.md @@ -0,0 +1,7 @@ +# OP Code of Conduct + +Open Packages can be pretty overpowered. + +They should be used for good. + +Please use the powers of this module for good, and report any abuse of this module. \ No newline at end of file diff --git a/contributing.md b/contributing.md new file mode 100644 index 0000000..8a1df1f --- /dev/null +++ b/contributing.md @@ -0,0 +1,11 @@ +# OP is Open + +Open Packages are pretty powerful. + +They create an Open Platform for rich local applications and services. + +We are _very_ open to ideas, integrations, and help. + +We want to be able to open anything as a package, and will accept any help to do so. + +Please consider filing an issue, starting a discussion, or contributing code. \ No newline at end of file diff --git a/security.md b/security.md new file mode 100644 index 0000000..84d21e8 --- /dev/null +++ b/security.md @@ -0,0 +1,160 @@ +# OP Security + +Many packages are zip files in a trenchcoat, and files can be dangerous. + +OP lets you work with all sorts of packages. This can be helpful, and can be dangerous. + +Lets think of things we can do with a package in terms of levels of risk: + +## Low Risk + +### Reading Packages + +It is generally safe to read files. + +OP helps you with this: you can make anything into a package, and see what's inside. + +OP provides an Open Platform for reading files and many ways to see what is inside and check the contents. + +It also provides ways to inspect any code inside of the package (see mitigations): + +### Showing Packages + +Because Open Packages have both file data and content types, we can easily serve them up and show them in a browser. + +IF the server does does now allow any other verb than `-Get`, and is run locally, this is fairly safe. + +It is more safe if not running as root or admin, and only interacting with well-known content types. + +## Medium Risk + +### Editing Packages + +Things start to get tricky when you can change a package. + +We want to enable package editing, but do so as safely as we can. + +OP can let you change packages with simple local PUT/POST/DELETE operations. + +But only if you `-Allow` it. If we are running a local web server, we might `-Allow` it. + +If we were running a remote web server with this flag on, we might be very foolish. + +To start a local server that can edit a package, we can run: + +~~~PowerShell +$package | + Start-OpenPackage -Allow GET, HEAD, POST, PUT, DELETE +~~~ + +## High Risk + +### Invoking Packages + +Packages can contain code, and code can be run. + +As Open Packages can be an Open Platform for applications, we _can_ invoke from packages. + +We would be very foolish if we did this with packages we do not trust. + +## Mitigations + +In order to mitigate risks, Open Package provides several mitigations. + +### Package Metadata + +Open Packages contain metadata that most archives lack. + +Each Open Package has a [`.PackageProperties`](https://learn.microsoft.com/en-us/dotnet/api/system.io.packaging.packageproperties?wt.mc_id=MVP_321542) collection. + +This collection contains some useful information, such as the Identifier and Version. + +### Package Content Hashes + +It is trivial to get all of the hashes of files within a package. + +If these hashes deviate, the package has been changed. + +For example, if we wanted all of the hashes of a PowerShell gallery module, we can use: + +~~~PowerShell +$turtlePackage = Get-OpenPackage https://powershellgallery.com/Packages/Turtle +$turtlePackage.FileHash +~~~ + +### Package Data Inspection + +In addition to reading hashes, we can also read any file within the package. + +This allows us to scan packages. + +`Select-OpenPackage` can be quite handy. + +It allows us to select parts of a package by name, and allows us to search within package parts using: + +* Regular Expressions +* XPath +* PowerShell AST Conditions + +Additionally, each package has several properties to assist inspection. + +For example, `.package.json` will return any package.json files in the package. + +To assist in inspecting PowerShell packages, we can also look for specific Abstract Syntax Tree types: + +* `.GetPowerShellCommandAst` gets all commands referenced in a package +* `.GetPowerShellParameterAst` gets all parameters referenced in a package +* `.GetPowerShellTypeAst` gets all types referenced in a package + +To see every property an OpenPackage provides, use Get-Member: + +~~~PowerShell +$openPackage = Import-Module OP -PassThru | OP +$openPackage | Get-Member +~~~ + +### Locking Packages + +In order to prevent package alteration, we can `Lock-OpenPackage` to get a read-only version of the package. + +This will prevent alteration of the package, even if we choose the `-Allow` other http verbs in `Start-OpenPackage` + +### Eventing + +In order to prevent the loading of unwelcome packages or starting of unwelcome servers, many commands broadcast an event. + +For example: + +* `Get-OpenPackage` will be sent every time a package might be opened. +* `Start-OpenPackage` will be sent every time a package might be run as a server. + +These events can be used for logging of activity. + +We can also use them to prevent some execution + +### Preventing Execution + +Each event has a .MessageData dictionary. + +To prevent a command from executing, just say `no`. + +For example, we want to prevent Start-OpenPackage from launching any server, we can use: + +~~~PowerShell +Register-EngineEvent -SourceIdentifier Start-OpenPackage -Action { + Write-Host "Won't Start" + + $event.MessageData['No'] = 'way' +} +~~~ + +Adding any number of keys to an event's message will reject the event. + +Those properties are: + +* `No` +* `Deny` +* `Reject` +* `Rejected` + +If any of these properties are found, the command should stop processing. \ No newline at end of file