diff --git a/Build/Build-Module.ps1 b/Build/Build-Module.ps1 index f05f2293..746f2c4f 100644 --- a/Build/Build-Module.ps1 +++ b/Build/Build-Module.ps1 @@ -24,3 +24,6 @@ if ($NoBuild) { $invoke.NoBuild = $true } if ($Json) { $invoke.Json = $true } & $script @invoke +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} diff --git a/CHANGELOG.MD b/CHANGELOG.MD index f4bbbe3d..2b7e4125 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,5 +1,11 @@ # PSPublishModule Changelog +## Unreleased +- Add reusable dotnet publish command hooks (`Hooks[]`) for restore, build, target publish, and bundle phases. +- Add bundle composition primitives for primary subdirectories, copy items, module includes, and generated scripts. +- Add bundle post-process signing and MSI harvest exclude patterns for package/MSI reuse across projects. +- DotNet publish CLI overrides (`--target`, `--runtime`, `--framework`, `--style`) are applied before profile resolution so profile-filtered plan/export output matches the executed run. + ## 2.0.19 - 2025.06.17 ### What's Changed * Improve error handling in release utilities by @PrzemyslawKlys in https://github.com/EvotecIT/PSPublishModule/pull/32 @@ -221,4 +227,4 @@ I've removed the logic where Standard would always get loaded even if Default/Co ## 0.9.43 - 2022.04.14 - Small fixes for publishing modules with Standard Libraries only -- Improved building of Artefacts \ No newline at end of file +- Improved building of Artefacts diff --git a/Docs/PSPublishModule.DotNetPublish.Quickstart.md b/Docs/PSPublishModule.DotNetPublish.Quickstart.md index e64b97df..e0d317d2 100644 --- a/Docs/PSPublishModule.DotNetPublish.Quickstart.md +++ b/Docs/PSPublishModule.DotNetPublish.Quickstart.md @@ -52,6 +52,8 @@ Invoke-DotNetPublish ` -ExitCode ``` +CLI overrides are applied before profile resolution. When a profile is active, `-Target`, `-Runtimes`, `-Frameworks`, and `-Styles` constrain the active profile and matching targets so the exported/plan view reflects the same inputs the runner executes. + ## DSL Flow (When You Want Scripted Composition) ```powershell @@ -121,20 +123,48 @@ Depending on config, the run can emit: - Use `Bundles[]` when the shipped artifact is more than one publish output folder. - `PrepareFromTarget` selects the primary published target for the bundle. +- `PrimarySubdirectory` optionally places that primary publish output under a folder such as `Service` instead of copying it into the bundle root. - `Includes[]` let you copy sidecar targets such as services, workers, helper CLIs, or plugin payloads into subdirectories inside the bundle. +- `CopyItems[]` copies non-publish files or directories such as README files, static scripts, licenses, or product data into the bundle. +- `ModuleIncludes[]` copies built PowerShell module artefacts into the bundle, defaulting to `Modules/{moduleName}` so apps can ship their companion module without scraping user-profile installs. +- `GeneratedScripts[]` renders inline or file-based script templates into the bundle. Template values use `{{TokenName}}`; token values can use bundle tokens such as `{output}`, `{rid}`, `{framework}`, and `{moduleName}` for module includes. +- Bundle paths and token values use single braces such as `{output}`. Generated script template bodies use double braces such as `{{ModuleName}}`; `GeneratedScripts[].Tokens` bridges the two by resolving single-brace values first and then making them available to the double-brace template renderer. Token values are inserted literally; a value containing `{{OtherToken}}` is not rendered a second time. +- `GeneratedScripts[].Overwrite=false` means "fail if the target file already exists", not "skip if present". `CopyItems[].ClearDestination=false` and `ModuleIncludes[].ClearDestination=false` likewise preserve existing destinations by failing on copy conflicts. - `Scripts[]` let you run repo-specific finishing steps after the copy phase, for example exporting plugins, writing launchers, or producing metadata files. - `PostProcess.ArchiveDirectories[]` can zip bundle subdirectories after composition, which is useful for plugin packs or other nested payloads that should ship as archives. - `PostProcess.DeletePatterns[]` removes files or folders from the composed bundle using wildcard patterns such as `**/*.pdb` or `**/createdump.exe`. +- `PostProcess.SignProfile` / `PostProcess.Sign` signs files in the composed bundle before zip creation. Use `PostProcess.SignPatterns[]` for scripts and other bundle payloads, for example `**/*.exe`, `**/*.dll`, `**/*.ps1`, `**/*.psm1`, and `**/*.psd1`. - `PostProcess.Metadata` writes a JSON manifest from standard bundle properties plus templated custom values. - The same reusable post-process contract is also available outside the full publish pipeline through `powerforge dotnet bundle-postprocess` and `Invoke-PowerForgeBundlePostProcess`. - Standalone bundle post-process is useful when a repo keeps a thin PowerShell wrapper for product-specific launcher/readme generation but wants archive/delete/metadata mechanics to stay in PowerForge. - Set `Zip`, `ZipPath`, or `ZipNameTemplate` on the bundle when the composed folder should also become a release-ready archive. +## Plugin Catalogs + +- Use `powerforge.plugins.json` when several plugin projects need one reusable catalog for folder export and NuGet package builds. +- `powerforge plugin export` and `Invoke-PowerForgePluginExport` publish selected plugin groups into folder-style plugin payloads. +- `powerforge plugin pack` and `Invoke-PowerForgePluginPack` pack selected plugin groups as NuGet packages and can optionally push the packages produced by the current run. +- Plugin entries can define groups, project path, preferred framework, package/assembly names, MSBuild properties, and optional schema-neutral manifest output. +- Keep branded product policy in repo config. For example, use `Manifest.FileName` and `Manifest.Properties` for `ix-plugin.json` or similar product-specific manifest shapes instead of hardcoding those names into PowerForge. +- Example config and commands live in `Module/Examples/PluginCatalog`. +- For app bundles, prefer a bundle `Scripts[]` step that runs plugin export into a plugin subdirectory, then use `PostProcess.ArchiveDirectories[]` when those plugin folders should be zipped before the final bundle archive. + +## Command Hooks + +- Use `Hooks[]` for reusable command steps that must run at predictable publish phases rather than in bespoke wrapper scripts. +- Supported phases are `BeforeRestore`, `BeforeBuild`, `BeforeTargetPublish`, `AfterTargetPublish`, `BeforeBundle`, and `AfterBundle`. +- Hooks support target/runtime/framework/style filters, arguments, working directory, environment variables, timeout, and required/optional failure policy. +- Hook arguments and environment values support tokens such as `{projectRoot}`, `{configuration}`, `{target}`, `{rid}`, `{framework}`, `{style}`, `{bundle}`, `{phase}`, and `{hook}`. +- Hook timeout defaults to 600 seconds when omitted or set to `0`; set `TimeoutSeconds` lower for quick local validation hooks or higher for intentionally long-running packaging steps. +- `BeforeBundle` and `AfterBundle` hooks run once per generated bundle step. A bundle that matches multiple target/runtime/framework/style combinations will invoke matching bundle hooks once for each produced bundle artifact. +- Use `BeforeBuild` or `BeforeTargetPublish` for generated-source/catalog refreshes, and `BeforeBundle` or `AfterBundle` for product-specific package finishing that should stay repo-owned. + ## MSI From Bundle - Use `PrepareFromBundleId` on an installer when WiX harvesting should run against the composed portable bundle instead of the raw `PrepareFromTarget` publish folder. - This is the right pattern for desktop apps that need sidecars, plugins, helper scripts, or metadata to be present in the installer payload. - Keep `PrepareFromTarget` set as well; it remains the source combination that installer filters validate against. +- `HarvestExcludePatterns[]` accepts relative wildcard paths such as `**/*.pdb` and basename globs such as `createdump.exe`; basename globs apply anywhere under the harvested payload. ## Microsoft Store / MSIX Packaging diff --git a/Docs/PowerForge.BuildReuse.TestimoXTierBridge.md b/Docs/PowerForge.BuildReuse.TestimoXTierBridge.md new file mode 100644 index 00000000..f4f74eb5 --- /dev/null +++ b/Docs/PowerForge.BuildReuse.TestimoXTierBridge.md @@ -0,0 +1,236 @@ +# PowerForge Reusable Build Coverage: TestimoX and TierBridge + +This note maps the PowerShell-heavy build/deploy/catalog logic in TestimoX and TierBridge to the reusable capabilities already present in PSPublishModule/PowerForge, then proposes the smallest engine changes needed to make future repo scripts thin wrappers. + +## Consumer Inventory + +### TestimoX + +Relevant scripts and patterns: + +- `Build/Sync-PowerShellCatalog.ps1` + - Runs `dotnet run --project TestimoX.CLI ... ps-catalog --base-dir ... --output-dir ...`. + - Generates compiled PowerShell rule catalog source under `TestimoX/RulesPowerShell/RulesGenerated`. +- `Build/Publish-AOT.ps1` + - Owns restore/build, target selection (`CLI`, `Audit`, `Tools`, `Service`, `Agent`, `Both`, `All`), runtime matrix, style selection (`Portable`, `PortableCompat`, `PortableSize`, `AotSpeed`, `AotSize`), slim cleanup, optional catalog refresh, service install/start, and summary output. +- `Build/Deploy-CLI.ps1`, `Build/Deploy-Service.ps1`, `Build/Deploy-TestimoX.Monitoring.ps1`, `Build/Deploy-TestimoX.Agent.ps1` + - Publish, copy to distributable folders, zip outputs, and generate simple service scripts. +- `Build/Deploy.ps1` + - Composes CLI/service/monitoring outputs into a release root and writes `manifest.json` plus `manifest.txt`. +- `Build/Prepare-TestimoX.*-MSI.ps1` and `Build/Build-TestimoX.*-MSI.ps1` + - Stage payloads, write MSI manifests, harvest/build installer projects. +- `Build/*DashboardBenchmark*.ps1` and `Build/Helpers/Test-Dashboard*BenchmarkGate.ps1` + - Generate scenario data and enforce benchmark gates. +- `Module-TestimoX/Build/Build-Module.ps1`, `Module-ADPlayground/Build/Build-Module.ps1`, `Module-ComputerX/Build/Build-Module.ps1` + - Already use `Build-Module` / `New-ConfigurationBuild` for binary module merge/sign/package flows. + +### TierBridge + +Relevant scripts and patterns: + +- `Build/Deploy.ps1` + - Restores/builds solution, optionally builds/copies the PowerShell module, publishes service and CLI, signs binaries/scripts, creates package layout, generates wrapper scripts from templates, creates data folders from `appsettings.json`, copies README template, and zips the deployment. +- `Build/Rebuild.ps1` + - Stops service, builds/publishes service, preserves or clears local state, patches config from machine environment variables, configures audit policy, reinstalls service if needed, and starts/verifies service. +- `Build/Templates/*.wrapper.ps1` + - Thin user-facing wrappers that import the shipped module and call repo-specific module functions. +- `Module/Build/Build-Module.ps1` + - Already uses `Build-Module` / `New-ConfigurationBuild` for the PowerTierBridge module. + +## Existing PowerForge Coverage + +PowerForge already covers a large part of both repos' custom PowerShell: + +- Module builds: + - `Invoke-ModuleBuild` / `Build-Module` + - binary module merge/copy/sign/install + - `New-ConfigurationManifest`, `New-ConfigurationBuild`, `New-ConfigurationArtefact`, validation, docs, formatting, module dependency handling +- DotNet publish: + - `Invoke-DotNetPublish`, `New-DotNetPublishConfig`, `New-ConfigurationDotNet*` + - project catalog (`Projects[]` + `ProjectId`) + - runtime/framework/style matrix + - restore/build/publish separation + - `Portable`, `PortableCompat`, `PortableSize`, `AotSpeed`, `AotSize`, `FrameworkDependent` + - target-level and style-level MSBuild properties + - output path templates, zip output, slim cleanup, symbol/docs/ref pruning + - signing profiles and per-target/installer signing + - service script/metadata generation (`Install-Service.ps1`, `Uninstall-Service.ps1`, `Run-Once.ps1`) + - service recovery and service lifecycle execution + - config bootstrap rules + - state preservation rules for rebuild scenarios + - bundles with includes, bundle scripts, metadata, delete patterns, nested archive rules, and zip output + - MSI prepare/build and installer-from-bundle flow + - benchmark gates + - output manifests/checksums/run reports +- Plugin/catalog lane: + - `powerforge plugin export` + - `powerforge plugin pack` + - `PowerForgePluginCatalogService` + - PowerShell cmdlet source exists for `Invoke-PowerForgePluginExport` and `Invoke-PowerForgePluginPack` +- Standalone bundle post-process: + - `powerforge dotnet bundle-postprocess` + - PowerShell cmdlet source exists for `Invoke-PowerForgeBundlePostProcess` + +## Immediate Gaps + +### 1. Plugin and bundle-postprocess cmdlets are not exported by the generated module + +The cmdlet source and generated docs exist: + +- `PSPublishModule/Cmdlets/InvokePowerForgePluginExportCommand.cs` +- `PSPublishModule/Cmdlets/InvokePowerForgePluginPackCommand.cs` +- `PSPublishModule/Cmdlets/InvokePowerForgeBundlePostProcessCommand.cs` +- `Module/Docs/Invoke-PowerForgePluginExport.md` +- `Module/Docs/Invoke-PowerForgePluginPack.md` +- `Module/Docs/Invoke-PowerForgeBundlePostProcess.md` + +But `Module/PSPublishModule.psd1` and `Module/PSPublishModule.psm1` do not include those cmdlets in `CmdletsToExport`. + +Next step: regenerate/fix the module build output so these cmdlets are exported, and add a focused test that binary-detected cmdlets and manifest exports agree. Do not hand-edit the generated bootstrapper as the primary fix. + +### 2. No first-class pre-publish generated-source/catalog step + +TestimoX needs `ps-catalog` before publish. Today this can stay as a thin script before `Invoke-DotNetPublish`, but PowerForge lacks a declarative "run this command before build/publish" contract inside `DotNetPublishSpec`. + +Recommended engine feature: + +- `Hooks` or `Commands` in DotNet publish specs with phases such as `BeforeRestore`, `BeforeBuild`, `BeforeTargetPublish`, `AfterTargetPublish`, `BeforeBundle`, `AfterBundle`. +- command path, args with tokens, working directory, timeout, required/fail policy, environment variables, and output capture. +- plan-mode visibility without execution. + +This would make TestimoX's `Sync-PowerShellCatalog.ps1` a one-line wrapper around config instead of a prerequisite hidden in bespoke publish scripts. + +### 3. Generic wrapper-script generation is narrower than TierBridge needs + +PowerForge can generate service install/uninstall/run-once scripts, and it has an internal `ScriptTemplateRenderer`. TierBridge needs package scripts that import the shipped module and call arbitrary module functions (`Install-TierBridgeService`, `Set-TierBridgeConfig`, `Get-TierBridgePermission`, etc.). + +Recommended engine feature: + +- Bundle/service `ScriptsToGenerate[]` or `Templates[]`. +- Inputs: template path or embedded template, output path, token map, overwrite policy, optional signing profile. +- Built-in tokens for package paths, module names, service names, runtime/framework/style, bundle output, and artifact paths. + +Keep function names and parameters repo-owned. Move only the template rendering/copy/sign mechanics to PowerForge. + +### 4. No declarative "include built module artefact in app bundle" + +TierBridge deploy detects the module name from `Build-Module.ps1`, optionally builds it, finds an installed module path, and copies it into `Modules/`. + +Recommended engine feature: + +- A module artefact include in DotNet bundles: + - `ModuleBuild` reference or command hook + - expected module name + - source path from known PowerForge module artefact output, not user profile install fallback + - bundle destination such as `Modules/{moduleName}` + +This would let app bundles depend on PowerForge module build outputs without scraping scripts or reading user profile module installs. + +### 5. Product policy should remain repo-local + +Do not move these into PowerForge as generic features yet: + +- TestimoX rule semantics and the `ps-catalog` CLI implementation. +- TierBridge config mutation from `VirusTotalApi` / `TierBridgeGraphSecret`. +- TierBridge audit policy bootstrap and folder ACL policy. +- Product README text, branding, default install paths, support instructions. +- Local sibling dependency policy such as `UseLocalFileInspectorX`, except as normal MSBuild properties in config. + +PowerForge should expose hooks and reusable mechanics; repos should keep product choices. + +## Proposed Migration Plan + +### Phase 0: Unblock existing reusable surfaces + +1. Regenerate/fix module exports for: + - `Invoke-PowerForgePluginExport` + - `Invoke-PowerForgePluginPack` + - `Invoke-PowerForgeBundlePostProcess` +2. Add `Schemas/powerforge.plugins.schema.json` and a small `Module/Examples/PluginCatalog` example. +3. Add tests for default plugin config discovery and manifest export parity. +4. Add a short section to `Docs/PSPublishModule.DotNetPublish.Quickstart.md` linking plugin catalogs and bundle post-process to app bundles. + +### Phase 1: Engine primitives before consumer rewrites + +Implement the reusable pieces before replacing repo scripts. Scope them to the patterns already proven by TierBridge and TestimoX, not a broad "everything build-related" abstraction. + +1. DotNet publish hooks/commands with plan-mode output. Implemented in this branch as `Hooks[]`: + - `BeforeRestore` + - `BeforeBuild` + - `BeforeTargetPublish` + - `AfterTargetPublish` + - `BeforeBundle` + - `AfterBundle` +2. Generic generated script/template outputs for bundle/package scripts. Implemented in this branch as `Bundles[].GeneratedScripts[]`: + - template path or embedded template + - output path + - token map + - overwrite policy + - optional signing profile +3. Module artefact bundle includes. Implemented in this branch as `Bundles[].ModuleIncludes[]`: + - reference a PowerForge module build output + - copy into `Modules/{moduleName}` or another configured destination + - avoid user-profile module install discovery as the primary source +4. MSI/package hardening. Implemented in this branch with `Bundles[].PrimarySubdirectory`, `Bundles[].CopyItems[]`, module includes, generated scripts, and the existing `PrepareFromBundleId` installer flow: + - ensure installer prepare/build can consume composed bundle outputs + - keep service package metadata, wrapper scripts, README, module payload, and MSI payload in one declarative release layout +5. Manifest/export parity tests for generated module files so new binary cmdlets do not exist in docs/source but disappear from the shipped module. + +### Phase 2: TierBridge package and MSI pilot + +1. Add `Build/powerforge.dotnetpublish.json` with targets for `TierBridge.Service` and `TierBridge.CLI`. +2. Move `UseLocalFileInspectorX` into `DotNet.MsBuildProperties` or target `MsBuildProperties`. +3. Use service package + lifecycle for install/reinstall/start/verify mechanics. +4. Use state preservation for data/config/log paths that must survive rebuilds. +5. Use bundles for `Service`, `CLI`, `Scripts`, `Modules`, `README.md`, and deployment zip. +6. Generate TierBridge wrapper scripts through the engine template output feature. +7. Include the built PowerTierBridge module through the module artefact bundle include. +8. Build the MSI from the composed bundle/package layout. +9. Keep config mutation/audit bootstrap repo-local behind explicit hooks. + +TierBridge is the better first consumer because it exercises the package, service, module-include, generated-wrapper, signing, zip, and MSI path without TestimoX's additional generated catalog and AOT matrix complexity. + +### Phase 3: TestimoX publish/catalog/MSI pilot + +1. Add `Build/powerforge.dotnetpublish.json` with project catalog entries for: + - `TestimoX.CLI` + - `TestimoX.Audit` + - `TestimoX.Service` + - `TestimoX.Agent` + - `TestimoX.Monitoring` +2. Model publish targets with style/rid/framework matrices instead of script loops. +3. Move `Build/Sync-PowerShellCatalog.ps1` into a declarative `BeforeBuild` or `BeforeTargetPublish` hook. +4. Replace `Deploy-CLI.ps1` and `Deploy-Service.ps1` mechanics with `Invoke-DotNetPublish -ConfigPath ... -Target ...`. +5. Use bundles for the unified `CLI/Service/Monitoring` release layout and `Outputs` for manifest/checksum/report generation. +6. Migrate MSI staging/build scripts to `Installers[]` after publish parity is verified. + +### Phase 4: Generalize only after both pilots + +After TierBridge and TestimoX both build through PowerForge, promote only duplicated repo patterns into engine contracts: + +1. Optional package layout manifest that can replace repo-specific `manifest.json` / `manifest.txt` builders. +2. Additional hook phases only if both repos need them. +3. Additional script-template tokens only when they replace duplicated wrapper logic. +4. Higher-level release profiles only if the existing target/bundle/installer model becomes repetitive. + +## Target End State + +Each repo should converge to: + +```powershell +param( + [ValidateSet('Release','Debug')] + [string] $Configuration = 'Release', + [string[]] $Runtime = @('win-x64'), + [switch] $Plan +) + +$config = Join-Path $PSScriptRoot 'powerforge.dotnetpublish.json' +Invoke-DotNetPublish -ConfigPath $config -Configuration $Configuration -Runtimes $Runtime -Plan:$Plan -ExitCode +``` + +Repo-specific scripts should only: + +- choose profile/target/runtime defaults +- pass secrets or product-specific paths +- run product-specific catalog/config/audit hooks until those are intentionally generalized diff --git a/Module/Examples/DotNetPublish/Example.PackageBundleMsi.json b/Module/Examples/DotNetPublish/Example.PackageBundleMsi.json new file mode 100644 index 00000000..b3db58cc --- /dev/null +++ b/Module/Examples/DotNetPublish/Example.PackageBundleMsi.json @@ -0,0 +1,111 @@ +{ + "$schema": "../../../Schemas/powerforge.dotnetpublish.schema.json", + "SchemaVersion": 1, + "DotNet": { + "ProjectRoot": ".", + "Configuration": "Release", + "Restore": true, + "Build": true, + "Runtimes": [ + "win-x64" + ] + }, + "Targets": [ + { + "Name": "Service", + "ProjectPath": "src/Product.Service/Product.Service.csproj", + "Publish": { + "Framework": "net10.0", + "Styles": [ + "PortableCompat" + ], + "Service": { + "ServiceName": "Product.Service", + "DisplayName": "Product Service" + } + } + }, + { + "Name": "Cli", + "ProjectPath": "src/Product.Cli/Product.Cli.csproj", + "Publish": { + "Framework": "net10.0", + "Styles": [ + "PortableCompat" + ] + } + } + ], + "Bundles": [ + { + "Id": "package", + "PrepareFromTarget": "Service", + "PrimarySubdirectory": "Service", + "Zip": true, + "Includes": [ + { + "Target": "Cli", + "Subdirectory": "CLI" + } + ], + "CopyItems": [ + { + "SourcePath": "Build/README.package.md", + "DestinationPath": "README.md", + "Required": false + } + ], + "ModuleIncludes": [ + { + "ModuleName": "PowerProduct", + "SourcePath": "Build/Artifacts/Modules/PowerProduct" + } + ], + "GeneratedScripts": [ + { + "Template": "Import-Module \"$PSScriptRoot\\Modules\\{{ModuleName}}\\{{ModuleName}}.psd1\" -Force\r\n{{InstallCommand}}\r\n", + "OutputPath": "Scripts/Install-Service.ps1", + "Tokens": { + "ModuleName": "PowerProduct", + "InstallCommand": "Install-ProductService" + } + } + ], + "PostProcess": { + "DeletePatterns": [ + "**/*.pdb", + "**/createdump.exe" + ], + "Metadata": { + "Path": "package.manifest.json", + "Properties": { + "moduleName": "PowerProduct", + "installScript": "Scripts/Install-Service.ps1" + } + } + } + } + ], + "Installers": [ + { + "Id": "Product.MSI", + "PrepareFromTarget": "Service", + "PrepareFromBundleId": "package", + "InstallerProjectPath": "Installer/Product.Installer.wixproj", + "Harvest": "Auto" + } + ], + "Hooks": [ + { + "Id": "sync-generated-sources", + "Phase": "BeforeBuild", + "Command": "pwsh", + "Arguments": [ + "-NoProfile", + "-File", + "Build/Sync-GeneratedSources.ps1" + ], + "Required": false + } + ] +} diff --git a/Module/Examples/DotNetPublish/README.md b/Module/Examples/DotNetPublish/README.md index db520acf..219df5f9 100644 --- a/Module/Examples/DotNetPublish/README.md +++ b/Module/Examples/DotNetPublish/README.md @@ -10,6 +10,8 @@ Files: - rebuild/state preservation pattern with service lifecycle. - `Example.PortableBundleMsi.json` - desktop app + service sidecar + portable bundle + MSI pattern. +- `Example.PackageBundleMsi.json` + - service/CLI package layout with README copy, module payload include, generated wrapper script, bundle post-process, and MSI from the composed bundle. - `Example.StorePackage.json` - desktop app + Store/MSIX packaging project pattern. - `Example.StoreSubmit.json` @@ -53,6 +55,7 @@ Invoke-DotNetPublish -ConfigPath '.\powerforge.dotnetpublish.json' -ExitCode - `Example.ServiceMsi.json` expects a WiX installer project (`*.wixproj`). - `Example.RebuildState.json` is aimed at preserve/restore deployments and service-aware rebuild flows. - `Example.PortableBundleMsi.json` shows how to use `Bundles`, include sidecar targets, and build an MSI from the composed bundle instead of the raw publish output. +- `Example.PackageBundleMsi.json` is the starter for TierBridge-style packages: composed service/CLI payload, shipped PowerShell module, generated scripts, ZIP, and MSI from the bundle. - `Example.StorePackage.json` shows how to keep Store/MSIX packaging in the same PowerForge publish matrix as the app target. - `Example.StoreSubmit.json` shows how to submit a packaged-app ZIP to Partner Center after `StorePackages[]` produces the `*.msixupload` or `*.appxupload` artifact. - `Example.StoreDesktopSubmit.json` shows how to submit MSI/EXE metadata through the newer desktop Store submission API. diff --git a/Module/Examples/PluginCatalog/README.md b/Module/Examples/PluginCatalog/README.md new file mode 100644 index 00000000..6a520605 --- /dev/null +++ b/Module/Examples/PluginCatalog/README.md @@ -0,0 +1,28 @@ +# PowerForge Plugin Catalog Example + +Use `powerforge.plugins.json` when a repo needs one catalog for folder-style plugin export and NuGet plugin package builds. + +Plan folder export: + +```powershell +Invoke-PowerForgePluginExport -ConfigPath .\powerforge.plugins.json -Group public -Plan +``` + +Export plugin folders: + +```powershell +Invoke-PowerForgePluginExport -ConfigPath .\powerforge.plugins.json -Group public -OutputRoot .\Artifacts\Plugins -ExitCode +``` + +Pack plugin NuGet packages: + +```powershell +Invoke-PowerForgePluginPack -ConfigPath .\powerforge.plugins.json -Group pack-public -OutputRoot .\Artifacts\NuGet -ExitCode +``` + +The CLI exposes the same engine: + +```powershell +powerforge plugin export --config .\powerforge.plugins.json --group public --plan +powerforge plugin pack --config .\powerforge.plugins.json --group pack-public --output-root .\Artifacts\NuGet +``` diff --git a/Module/Examples/PluginCatalog/powerforge.plugins.json b/Module/Examples/PluginCatalog/powerforge.plugins.json new file mode 100644 index 00000000..663bf11e --- /dev/null +++ b/Module/Examples/PluginCatalog/powerforge.plugins.json @@ -0,0 +1,42 @@ +{ + "$schema": "../../../Schemas/powerforge.plugins.schema.json", + "ProjectRoot": "../../..", + "Configuration": "Release", + "Catalog": [ + { + "Id": "sample-public-plugin", + "ProjectPath": "src/Sample.PublicPlugin/Sample.PublicPlugin.csproj", + "Groups": [ + "public", + "pack-public" + ], + "Framework": "net10.0", + "PackageId": "Sample.PublicPlugin", + "AssemblyName": "Sample.PublicPlugin", + "Manifest": { + "Enabled": true, + "FileName": "plugin.manifest.json", + "EntryTypeMatchBaseType": "IPluginContract", + "Properties": { + "displayName": "{packageId}", + "entryAssembly": "{entryAssembly}", + "entryType": "{entryType}" + } + } + }, + { + "Id": "sample-private-plugin", + "ProjectPath": "src/Sample.PrivatePlugin/Sample.PrivatePlugin.csproj", + "Groups": [ + "private", + "pack-private" + ], + "MsBuildProperties": { + "ExternalRoot": "{projectRoot}" + }, + "Manifest": { + "Enabled": false + } + } + ] +} diff --git a/Module/PSPublishModule.psd1 b/Module/PSPublishModule.psd1 index ed3ce615..71aae4bf 100644 --- a/Module/PSPublishModule.psd1 +++ b/Module/PSPublishModule.psd1 @@ -1,7 +1,7 @@ @{ - AliasesToExport = @('Build-Module', 'Invoke-ModuleBuilder', 'New-PrepareModule') + AliasesToExport = @('Build-Module', 'Connect-Gallery', 'Invoke-ModuleBuilder', 'New-PrepareModule', 'Register-Gallery') Author = 'Przemyslaw Klys' - CmdletsToExport = @('Connect-ModuleRepository', 'Convert-ProjectConsistency', 'Export-CertificateForNuGet', 'Get-MissingFunctions', 'Get-ModuleInformation', 'Get-ModuleTestFailures', 'Get-PowerShellAssemblyMetadata', 'Get-PowerShellCompatibility', 'Get-ProjectConsistency', 'Get-ProjectVersion', 'Install-PrivateModule', 'Invoke-DotNetPublish', 'Invoke-DotNetReleaseBuild', 'Invoke-DotNetRepositoryRelease', 'Invoke-ModuleBuild', 'Invoke-ModuleTestSuite', 'Invoke-ProjectBuild', 'New-ConfigurationArtefact', 'New-ConfigurationBuild', 'New-ConfigurationCommand', 'New-ConfigurationCompatibility', 'New-ConfigurationDelivery', 'New-ConfigurationDocumentation', 'New-ConfigurationDotNetBenchmarkGate', 'New-ConfigurationDotNetBenchmarkMetric', 'New-ConfigurationDotNetConfigBootstrapRule', 'New-ConfigurationDotNetInstaller', 'New-ConfigurationDotNetMatrix', 'New-ConfigurationDotNetMatrixRule', 'New-ConfigurationDotNetProfile', 'New-ConfigurationDotNetProject', 'New-ConfigurationDotNetPublish', 'New-ConfigurationDotNetService', 'New-ConfigurationDotNetServiceLifecycle', 'New-ConfigurationDotNetServiceRecovery', 'New-ConfigurationDotNetSign', 'New-ConfigurationDotNetState', 'New-ConfigurationDotNetStateRule', 'New-ConfigurationDotNetTarget', 'New-ConfigurationExecute', 'New-ConfigurationFileConsistency', 'New-ConfigurationFormat', 'New-ConfigurationImportModule', 'New-ConfigurationInformation', 'New-ConfigurationManifest', 'New-ConfigurationModule', 'New-ConfigurationModuleSkip', 'New-ConfigurationPlaceHolder', 'New-ConfigurationPublish', 'New-ConfigurationTest', 'New-ConfigurationValidation', 'New-DotNetPublishConfig', 'New-ModuleAboutTopic', 'Publish-GitHubReleaseAsset', 'Publish-NugetPackage', 'Register-Certificate', 'Register-ModuleRepository', 'Remove-Comments', 'Remove-ProjectFiles', 'Send-GitHubRelease', 'Set-ProjectVersion', 'Step-Version', 'Update-ModuleRepository', 'Update-PrivateModule') + CmdletsToExport = @('Connect-ModuleRepository', 'Convert-ProjectConsistency', 'Export-CertificateForNuGet', 'Export-ConfigurationProject', 'Get-MissingFunctions', 'Get-ModuleInformation', 'Get-ModuleTestFailures', 'Get-PowerShellAssemblyMetadata', 'Get-PowerShellCompatibility', 'Get-ProjectConsistency', 'Get-ProjectVersion', 'Import-ConfigurationProject', 'Install-PrivateModule', 'Invoke-DotNetPublish', 'Invoke-DotNetReleaseBuild', 'Invoke-DotNetRepositoryRelease', 'Invoke-ModuleBuild', 'Invoke-ModuleTestSuite', 'Invoke-PowerForgeBundlePostProcess', 'Invoke-PowerForgePluginExport', 'Invoke-PowerForgePluginPack', 'Invoke-PowerForgeRelease', 'Invoke-ProjectBuild', 'Invoke-ProjectRelease', 'New-ConfigurationArtefact', 'New-ConfigurationBuild', 'New-ConfigurationCommand', 'New-ConfigurationCompatibility', 'New-ConfigurationDelivery', 'New-ConfigurationDocumentation', 'New-ConfigurationDotNetBenchmarkGate', 'New-ConfigurationDotNetBenchmarkMetric', 'New-ConfigurationDotNetConfigBootstrapRule', 'New-ConfigurationDotNetInstaller', 'New-ConfigurationDotNetMatrix', 'New-ConfigurationDotNetMatrixRule', 'New-ConfigurationDotNetProfile', 'New-ConfigurationDotNetProject', 'New-ConfigurationDotNetPublish', 'New-ConfigurationDotNetService', 'New-ConfigurationDotNetServiceLifecycle', 'New-ConfigurationDotNetServiceRecovery', 'New-ConfigurationDotNetSign', 'New-ConfigurationDotNetState', 'New-ConfigurationDotNetStateRule', 'New-ConfigurationDotNetTarget', 'New-ConfigurationExecute', 'New-ConfigurationFileConsistency', 'New-ConfigurationFormat', 'New-ConfigurationImportModule', 'New-ConfigurationInformation', 'New-ConfigurationManifest', 'New-ConfigurationModule', 'New-ConfigurationModuleSkip', 'New-ConfigurationPlaceHolder', 'New-ConfigurationProject', 'New-ConfigurationProjectInstaller', 'New-ConfigurationProjectOutput', 'New-ConfigurationProjectRelease', 'New-ConfigurationProjectSigning', 'New-ConfigurationProjectTarget', 'New-ConfigurationProjectWorkspace', 'New-ConfigurationPublish', 'New-ConfigurationTest', 'New-ConfigurationValidation', 'New-DotNetPublishConfig', 'New-ModuleAboutTopic', 'New-PowerForgeReleaseConfig', 'New-ProjectReleaseConfig', 'Publish-GitHubReleaseAsset', 'Publish-NugetPackage', 'Register-Certificate', 'Register-ModuleRepository', 'Remove-Comments', 'Remove-ProjectFiles', 'Send-GitHubRelease', 'Set-ProjectVersion', 'Step-Version', 'Update-ModuleRepository', 'Update-PrivateModule') CompanyName = 'Evotec' CompatiblePSEditions = @('Desktop', 'Core') Copyright = '(c) 2011 - 2026 Przemyslaw Klys @ Evotec. All rights reserved.' diff --git a/Module/PSPublishModule.psm1 b/Module/PSPublishModule.psm1 index a432877c..99e3721a 100644 --- a/Module/PSPublishModule.psm1 +++ b/Module/PSPublishModule.psm1 @@ -75,6 +75,6 @@ if (Test-Path -LiteralPath $LibrariesScript) { } $FunctionsToExport = @() -$CmdletsToExport = @('Connect-ModuleRepository', 'Convert-ProjectConsistency', 'Export-CertificateForNuGet', 'Get-MissingFunctions', 'Get-ModuleInformation', 'Get-ModuleTestFailures', 'Get-PowerShellAssemblyMetadata', 'Get-PowerShellCompatibility', 'Get-ProjectConsistency', 'Get-ProjectVersion', 'Install-PrivateModule', 'Invoke-DotNetPublish', 'Invoke-DotNetReleaseBuild', 'Invoke-DotNetRepositoryRelease', 'Invoke-ModuleBuild', 'Invoke-ModuleTestSuite', 'Invoke-ProjectBuild', 'New-ConfigurationArtefact', 'New-ConfigurationBuild', 'New-ConfigurationCommand', 'New-ConfigurationCompatibility', 'New-ConfigurationDelivery', 'New-ConfigurationDocumentation', 'New-ConfigurationDotNetBenchmarkGate', 'New-ConfigurationDotNetBenchmarkMetric', 'New-ConfigurationDotNetConfigBootstrapRule', 'New-ConfigurationDotNetInstaller', 'New-ConfigurationDotNetMatrix', 'New-ConfigurationDotNetMatrixRule', 'New-ConfigurationDotNetProfile', 'New-ConfigurationDotNetProject', 'New-ConfigurationDotNetPublish', 'New-ConfigurationDotNetService', 'New-ConfigurationDotNetServiceLifecycle', 'New-ConfigurationDotNetServiceRecovery', 'New-ConfigurationDotNetSign', 'New-ConfigurationDotNetState', 'New-ConfigurationDotNetStateRule', 'New-ConfigurationDotNetTarget', 'New-ConfigurationExecute', 'New-ConfigurationFileConsistency', 'New-ConfigurationFormat', 'New-ConfigurationImportModule', 'New-ConfigurationInformation', 'New-ConfigurationManifest', 'New-ConfigurationModule', 'New-ConfigurationModuleSkip', 'New-ConfigurationPlaceHolder', 'New-ConfigurationPublish', 'New-ConfigurationTest', 'New-ConfigurationValidation', 'New-DotNetPublishConfig', 'New-ModuleAboutTopic', 'Publish-GitHubReleaseAsset', 'Publish-NugetPackage', 'Register-Certificate', 'Register-ModuleRepository', 'Remove-Comments', 'Remove-ProjectFiles', 'Send-GitHubRelease', 'Set-ProjectVersion', 'Step-Version', 'Update-ModuleRepository', 'Update-PrivateModule') -$AliasesToExport = @('Build-Module', 'Invoke-ModuleBuilder', 'New-PrepareModule') +$CmdletsToExport = @('Connect-ModuleRepository', 'Convert-ProjectConsistency', 'Export-CertificateForNuGet', 'Export-ConfigurationProject', 'Get-MissingFunctions', 'Get-ModuleInformation', 'Get-ModuleTestFailures', 'Get-PowerShellAssemblyMetadata', 'Get-PowerShellCompatibility', 'Get-ProjectConsistency', 'Get-ProjectVersion', 'Import-ConfigurationProject', 'Install-PrivateModule', 'Invoke-DotNetPublish', 'Invoke-DotNetReleaseBuild', 'Invoke-DotNetRepositoryRelease', 'Invoke-ModuleBuild', 'Invoke-ModuleTestSuite', 'Invoke-PowerForgeBundlePostProcess', 'Invoke-PowerForgePluginExport', 'Invoke-PowerForgePluginPack', 'Invoke-PowerForgeRelease', 'Invoke-ProjectBuild', 'Invoke-ProjectRelease', 'New-ConfigurationArtefact', 'New-ConfigurationBuild', 'New-ConfigurationCommand', 'New-ConfigurationCompatibility', 'New-ConfigurationDelivery', 'New-ConfigurationDocumentation', 'New-ConfigurationDotNetBenchmarkGate', 'New-ConfigurationDotNetBenchmarkMetric', 'New-ConfigurationDotNetConfigBootstrapRule', 'New-ConfigurationDotNetInstaller', 'New-ConfigurationDotNetMatrix', 'New-ConfigurationDotNetMatrixRule', 'New-ConfigurationDotNetProfile', 'New-ConfigurationDotNetProject', 'New-ConfigurationDotNetPublish', 'New-ConfigurationDotNetService', 'New-ConfigurationDotNetServiceLifecycle', 'New-ConfigurationDotNetServiceRecovery', 'New-ConfigurationDotNetSign', 'New-ConfigurationDotNetState', 'New-ConfigurationDotNetStateRule', 'New-ConfigurationDotNetTarget', 'New-ConfigurationExecute', 'New-ConfigurationFileConsistency', 'New-ConfigurationFormat', 'New-ConfigurationImportModule', 'New-ConfigurationInformation', 'New-ConfigurationManifest', 'New-ConfigurationModule', 'New-ConfigurationModuleSkip', 'New-ConfigurationPlaceHolder', 'New-ConfigurationProject', 'New-ConfigurationProjectInstaller', 'New-ConfigurationProjectOutput', 'New-ConfigurationProjectRelease', 'New-ConfigurationProjectSigning', 'New-ConfigurationProjectTarget', 'New-ConfigurationProjectWorkspace', 'New-ConfigurationPublish', 'New-ConfigurationTest', 'New-ConfigurationValidation', 'New-DotNetPublishConfig', 'New-ModuleAboutTopic', 'New-PowerForgeReleaseConfig', 'New-ProjectReleaseConfig', 'Publish-GitHubReleaseAsset', 'Publish-NugetPackage', 'Register-Certificate', 'Register-ModuleRepository', 'Remove-Comments', 'Remove-ProjectFiles', 'Send-GitHubRelease', 'Set-ProjectVersion', 'Step-Version', 'Update-ModuleRepository', 'Update-PrivateModule') +$AliasesToExport = @('Build-Module', 'Connect-Gallery', 'Invoke-ModuleBuilder', 'New-PrepareModule', 'Register-Gallery') Export-ModuleMember -Function $FunctionsToExport -Alias $AliasesToExport -Cmdlet $CmdletsToExport diff --git a/PSPublishModule/Cmdlets/InvokeDotNetPublishCommand.cs b/PSPublishModule/Cmdlets/InvokeDotNetPublishCommand.cs index 371a6cdf..c45dc5d2 100644 --- a/PSPublishModule/Cmdlets/InvokeDotNetPublishCommand.cs +++ b/PSPublishModule/Cmdlets/InvokeDotNetPublishCommand.cs @@ -48,6 +48,13 @@ public sealed class InvokeDotNetPublishCommand : PSCmdlet [ValidateNotNullOrEmpty] public string ConfigPath { get; set; } = string.Empty; + /// + /// Optional project root override used to resolve relative publish inputs and outputs. + /// + [Parameter(ParameterSetName = ParameterSetSettings)] + [Parameter(ParameterSetName = ParameterSetConfig)] + public string? ProjectRoot { get; set; } + /// /// Optional profile override. /// @@ -163,6 +170,7 @@ protected override void ProcessRecord() ResolvePath = path => SessionState.Path.GetUnresolvedProviderPathFromPSPath(path), Settings = Settings, ConfigPath = ConfigPath, + ProjectRoot = ProjectRoot, Profile = Profile, Target = Target, Runtimes = Runtimes, diff --git a/PSPublishModule/Cmdlets/InvokeModuleBuildCommand.cs b/PSPublishModule/Cmdlets/InvokeModuleBuildCommand.cs index d3994d12..38e12b3e 100644 --- a/PSPublishModule/Cmdlets/InvokeModuleBuildCommand.cs +++ b/PSPublishModule/Cmdlets/InvokeModuleBuildCommand.cs @@ -87,6 +87,7 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet { private const string ParameterSetModern = "Modern"; private const string ParameterSetConfiguration = "Configuration"; + private const string ParameterSetConfig = "Config"; /// /// Provides settings for the module in the form of a script block (DSL). @@ -94,6 +95,13 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet [Parameter(Position = 0, ParameterSetName = ParameterSetModern)] public ScriptBlock? Settings { get; set; } + /// + /// Path to a module pipeline JSON config generated by Invoke-ModuleBuild -JsonOnly. + /// + [Parameter(Mandatory = true, ParameterSetName = ParameterSetConfig)] + [ValidateNotNullOrEmpty] + public string ConfigPath { get; set; } = string.Empty; + /// /// Path to the parent folder where the project exists or should be created. The module project resolves to Path\ModuleName. When omitted, uses the parent of the calling script directory. /// @@ -137,6 +145,7 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet /// [Parameter(ParameterSetName = ParameterSetModern)] [Parameter(ParameterSetName = ParameterSetConfiguration)] + [Parameter(ParameterSetName = ParameterSetConfig)] public string[] ExcludeDirectories { get; set; } = { ".git", @@ -161,6 +170,7 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet /// [Parameter(ParameterSetName = ParameterSetModern)] [Parameter(ParameterSetName = ParameterSetConfiguration)] + [Parameter(ParameterSetName = ParameterSetConfig)] public string[] ExcludeFiles { get; set; } = { ".gitignore" }; /// @@ -216,6 +226,7 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet /// [Parameter(ParameterSetName = ParameterSetModern)] [Parameter(ParameterSetName = ParameterSetConfiguration)] + [Parameter(ParameterSetName = ParameterSetConfig)] public SwitchParameter Legacy { get; set; } /// @@ -223,6 +234,7 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet /// [Parameter(ParameterSetName = ParameterSetModern)] [Parameter(ParameterSetName = ParameterSetConfiguration)] + [Parameter(ParameterSetName = ParameterSetConfig)] public SwitchParameter NoInteractive { get; set; } /// Staging directory for the PowerForge pipeline. When omitted, a temporary folder is generated. @@ -276,6 +288,7 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet /// [Parameter(ParameterSetName = ParameterSetModern)] [Parameter(ParameterSetName = ParameterSetConfiguration)] + [Parameter(ParameterSetName = ParameterSetConfig)] public SwitchParameter JsonOnly { get; set; } /// @@ -284,6 +297,7 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet /// [Parameter(ParameterSetName = ParameterSetModern)] [Parameter(ParameterSetName = ParameterSetConfiguration)] + [Parameter(ParameterSetName = ParameterSetConfig)] public string? JsonPath { get; set; } /// @@ -291,6 +305,7 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet /// [Parameter(ParameterSetName = ParameterSetModern)] [Parameter(ParameterSetName = ParameterSetConfiguration)] + [Parameter(ParameterSetName = ParameterSetConfig)] public string? DiagnosticsBaselinePath { get; set; } /// @@ -298,6 +313,7 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet /// [Parameter(ParameterSetName = ParameterSetModern)] [Parameter(ParameterSetName = ParameterSetConfiguration)] + [Parameter(ParameterSetName = ParameterSetConfig)] public SwitchParameter GenerateDiagnosticsBaseline { get; set; } /// @@ -305,6 +321,7 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet /// [Parameter(ParameterSetName = ParameterSetModern)] [Parameter(ParameterSetName = ParameterSetConfiguration)] + [Parameter(ParameterSetName = ParameterSetConfig)] public SwitchParameter UpdateDiagnosticsBaseline { get; set; } /// @@ -312,6 +329,7 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet /// [Parameter(ParameterSetName = ParameterSetModern)] [Parameter(ParameterSetName = ParameterSetConfiguration)] + [Parameter(ParameterSetName = ParameterSetConfig)] public SwitchParameter FailOnNewDiagnostics { get; set; } /// @@ -319,6 +337,7 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet /// [Parameter(ParameterSetName = ParameterSetModern)] [Parameter(ParameterSetName = ParameterSetConfiguration)] + [Parameter(ParameterSetName = ParameterSetConfig)] [ValidateSet(nameof(BuildDiagnosticSeverity.Warning), nameof(BuildDiagnosticSeverity.Error))] public BuildDiagnosticSeverity? FailOnDiagnosticsSeverity { get; set; } @@ -328,6 +347,7 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet /// [Parameter(ParameterSetName = ParameterSetModern)] [Parameter(ParameterSetName = ParameterSetConfiguration)] + [Parameter(ParameterSetName = ParameterSetConfig)] public string[]? DiagnosticsBinaryConflictSearchRoot { get; set; } /// @@ -335,6 +355,7 @@ public sealed partial class InvokeModuleBuildCommand : PSCmdlet /// [Parameter(ParameterSetName = ParameterSetModern)] [Parameter(ParameterSetName = ParameterSetConfiguration)] + [Parameter(ParameterSetName = ParameterSetConfig)] public SwitchParameter ExitCode { get; set; } /// @@ -364,6 +385,7 @@ protected override void ProcessRecord() ParameterSetName = ParameterSetName, Settings = Settings, Configuration = Configuration, + ConfigPath = ConfigPath, ModuleName = ModuleName, InputPath = Path, StagingPath = StagingPath, @@ -406,10 +428,7 @@ protected override void ProcessRecord() return; } - var useLegacy = - Legacy.IsPresent || - ParameterSetName == ParameterSetConfiguration || - Settings is not null; + var useLegacy = preparation.UseLegacy; #pragma warning disable CA1031 // Legacy cmdlet UX: capture and report errors consistently BufferedLogger? interactiveBuffer = null; @@ -440,7 +459,7 @@ protected override void ProcessRecord() plan: plan, configLabel: configLabel), writeSummary: SpectrePipelineConsoleUi.WriteSummary) - .Execute(preparation, interactive, useLegacy ? "dsl" : "cmdlet"); + .Execute(preparation, interactive, preparation.ConfigLabel); } #pragma warning restore CA1031 diff --git a/PowerForge.Cli/Program.Command.DotNet.cs b/PowerForge.Cli/Program.Command.DotNet.cs index ff711276..74136685 100644 --- a/PowerForge.Cli/Program.Command.DotNet.cs +++ b/PowerForge.Cli/Program.Command.DotNet.cs @@ -82,7 +82,7 @@ private static int CommandDotNet(string[] filteredArgs, CliOptions cli, ILogger : CreateCommandLogger(outputJson, cli, logger); var loaded = LoadDotNetPublishSpecWithPath(configPath); - var spec = loaded.Value; + var spec = CloneDotNetPublishSpec(loaded.Value); var specPath = loaded.FullPath; var matrixOverrides = ParseDotNetPublishMatrixOverrides(subArgs); var effectiveRids = overrideRids.Length > 0 ? overrideRids : matrixOverrides.Runtimes; @@ -94,8 +94,10 @@ private static int CommandDotNet(string[] filteredArgs, CliOptions cli, ILogger var runner = new DotNetPublishPipelineRunner(cmdLogger); if (!string.IsNullOrWhiteSpace(overrideProfile)) spec.Profile = overrideProfile.Trim(); + if (effectiveStyles.Length > 1 && GetActiveDotNetPublishProfile(spec) is not null) + cmdLogger.Warn("Multiple --style values were provided with an active profile; the single-value profile style filter is ignored and target styles drive matrix expansion."); + ApplyDotNetPublishSpecOverrides(spec, overrideTargets, effectiveRids, effectiveFrameworks, effectiveStyles); var plan = runner.Plan(spec, specPath); - ApplyDotNetPublishPlanOverrides(plan, overrideTargets, effectiveRids, effectiveFrameworks, effectiveStyles); ApplyDotNetPublishSkipFlags(plan, skipRestore, skipBuild); if (validateOnly) diff --git a/PowerForge.Cli/Program.Helpers.RunAndParse.cs b/PowerForge.Cli/Program.Helpers.RunAndParse.cs index 229ff1a9..ab36264e 100644 --- a/PowerForge.Cli/Program.Helpers.RunAndParse.cs +++ b/PowerForge.Cli/Program.Helpers.RunAndParse.cs @@ -422,6 +422,129 @@ static DotNetPublishStyle ParseDotNetPublishStyle(string? value) throw new ArgumentException($"Unknown style: {raw}. Expected one of: {string.Join(", ", Enum.GetNames(typeof(DotNetPublishStyle)))}", nameof(value)); } + static DotNetPublishSpec CloneDotNetPublishSpec(DotNetPublishSpec spec) + { + if (spec is null) throw new ArgumentNullException(nameof(spec)); + + var json = JsonSerializer.Serialize(spec, CliJson.Context.DotNetPublishSpec); + return JsonSerializer.Deserialize(json, CliJson.Context.DotNetPublishSpec) + ?? throw new InvalidOperationException("Failed to clone dotnet publish spec."); + } + + static void ApplyDotNetPublishSpecOverrides( + DotNetPublishSpec spec, + string[] overrideTargets, + string[] overrideRids, + string[] overrideFrameworks, + DotNetPublishStyle[] overrideStyles) + { + if (spec is null) throw new ArgumentNullException(nameof(spec)); + + var targets = (spec.Targets ?? Array.Empty()) + .Where(t => t is not null) + .ToArray(); + var selectedTargetNames = NormalizeCliStringSet(overrideTargets); + var activeProfile = GetActiveDotNetPublishProfile(spec); + + if (selectedTargetNames.Length > 0) + { + var missing = selectedTargetNames + .Where(n => targets.All(t => !t.Name.Equals(n, StringComparison.OrdinalIgnoreCase))) + .ToArray(); + if (missing.Length > 0) + throw new ArgumentException($"Unknown target(s): {string.Join(", ", missing)}", nameof(overrideTargets)); + + if (activeProfile is not null) + { + activeProfile.Targets = selectedTargetNames; + } + else + { + spec.Targets = targets + .Where(t => selectedTargetNames.Contains(t.Name, StringComparer.OrdinalIgnoreCase)) + .ToArray(); + targets = spec.Targets; + } + } + + var runtimes = NormalizeCliStringSet(overrideRids); + if (runtimes.Length > 0) + { + // Profile overrides constrain the selected matrix; target overrides drive publish expansion. + if (activeProfile is not null) + activeProfile.Runtimes = runtimes; + + foreach (var target in targets) + { + if (target.Publish is null) continue; + target.Publish.Runtimes = runtimes; + } + } + + var frameworks = NormalizeCliStringSet(overrideFrameworks); + if (frameworks.Length > 0) + { + // Keep both layers aligned so plan/export output matches the executed publish run. + if (activeProfile is not null) + activeProfile.Frameworks = frameworks; + + foreach (var target in targets) + { + if (target.Publish is null) continue; + // Singular Framework is kept for compatibility; Frameworks drives matrix expansion. + target.Publish.Framework = frameworks[0]; + target.Publish.Frameworks = frameworks; + } + } + + var styles = (overrideStyles ?? Array.Empty()) + .Distinct() + .ToArray(); + if (styles.Length > 0) + { + // Profile style is single-valued; multiple CLI styles are represented on targets for planner expansion. + if (activeProfile is not null) + activeProfile.Style = styles.Length == 1 ? styles[0] : null; + + foreach (var target in targets) + { + if (target.Publish is null) continue; + target.Publish.Style = styles[0]; + target.Publish.Styles = styles; + } + } + } + + static DotNetPublishProfile? GetActiveDotNetPublishProfile(DotNetPublishSpec spec) + { + var profiles = (spec.Profiles ?? Array.Empty()) + .Where(p => p is not null && !string.IsNullOrWhiteSpace(p.Name)) + .ToArray(); + if (profiles.Length == 0) + return null; + + var profileName = !string.IsNullOrWhiteSpace(spec.Profile) + ? spec.Profile!.Trim() + : profiles.FirstOrDefault(p => p.Default)?.Name; + if (string.IsNullOrWhiteSpace(profileName)) + return null; + + var profile = profiles.FirstOrDefault(p => p.Name.Equals(profileName, StringComparison.OrdinalIgnoreCase)); + if (profile is null) + throw new ArgumentException($"Profile '{profileName}' was not found.", nameof(spec)); + + return profile; + } + + static string[] NormalizeCliStringSet(IEnumerable? values) + { + return (values ?? Array.Empty()) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(s => s.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + static void ApplyDotNetPublishPlanOverrides( DotNetPublishPlan plan, string[] overrideTargets, diff --git a/PowerForge.PowerShell/Models/DotNetPublishPreparationRequest.cs b/PowerForge.PowerShell/Models/DotNetPublishPreparationRequest.cs index c30c08a7..ba7dedd5 100644 --- a/PowerForge.PowerShell/Models/DotNetPublishPreparationRequest.cs +++ b/PowerForge.PowerShell/Models/DotNetPublishPreparationRequest.cs @@ -8,6 +8,7 @@ internal sealed class DotNetPublishPreparationRequest public string CurrentPath { get; set; } = string.Empty; public ScriptBlock? Settings { get; set; } public string? ConfigPath { get; set; } + public string? ProjectRoot { get; set; } public string? Profile { get; set; } public string[]? Target { get; set; } public string[]? Runtimes { get; set; } diff --git a/PowerForge.PowerShell/Models/ModuleBuildPreparationRequest.cs b/PowerForge.PowerShell/Models/ModuleBuildPreparationRequest.cs index d3284e58..9dc84406 100644 --- a/PowerForge.PowerShell/Models/ModuleBuildPreparationRequest.cs +++ b/PowerForge.PowerShell/Models/ModuleBuildPreparationRequest.cs @@ -8,6 +8,7 @@ internal sealed class ModuleBuildPreparationRequest public string ParameterSetName { get; set; } = string.Empty; public ScriptBlock? Settings { get; set; } public IDictionary Configuration { get; set; } = new Hashtable(); + public string? ConfigPath { get; set; } public string? ModuleName { get; set; } public string? InputPath { get; set; } public string? StagingPath { get; set; } diff --git a/PowerForge.PowerShell/Models/ModuleBuildPreparedContext.cs b/PowerForge.PowerShell/Models/ModuleBuildPreparedContext.cs index 5b95cba8..2efea1ea 100644 --- a/PowerForge.PowerShell/Models/ModuleBuildPreparedContext.cs +++ b/PowerForge.PowerShell/Models/ModuleBuildPreparedContext.cs @@ -8,4 +8,6 @@ internal sealed class ModuleBuildPreparedContext public bool UseLegacy { get; set; } public ModulePipelineSpec PipelineSpec { get; set; } = new(); public string? JsonOutputPath { get; set; } + public string ConfigLabel { get; set; } = "cmdlet"; + public string? ConfigFilePath { get; set; } } diff --git a/PowerForge.PowerShell/Services/DotNetPublishPreparationService.cs b/PowerForge.PowerShell/Services/DotNetPublishPreparationService.cs index b946a46f..632afed2 100644 --- a/PowerForge.PowerShell/Services/DotNetPublishPreparationService.cs +++ b/PowerForge.PowerShell/Services/DotNetPublishPreparationService.cs @@ -25,6 +25,11 @@ public DotNetPublishPreparedContext Prepare(DotNetPublishPreparationRequest requ var spec = LoadSpec(request, ref sourceLabel, warn); if (!string.IsNullOrWhiteSpace(request.Profile)) spec.Profile = request.Profile!.Trim(); + if (!string.IsNullOrWhiteSpace(request.ProjectRoot)) + { + spec.DotNet ??= new DotNetPublishDotNetOptions(); + spec.DotNet.ProjectRoot = request.ResolvePath!(request.ProjectRoot!); + } ApplyOverrides(spec, request); return new DotNetPublishPreparedContext diff --git a/PowerForge.PowerShell/Services/ModuleBuildPreparationService.cs b/PowerForge.PowerShell/Services/ModuleBuildPreparationService.cs index 164da372..76f6bd41 100644 --- a/PowerForge.PowerShell/Services/ModuleBuildPreparationService.cs +++ b/PowerForge.PowerShell/Services/ModuleBuildPreparationService.cs @@ -18,6 +18,9 @@ public ModuleBuildPreparedContext Prepare(ModuleBuildPreparationRequest request) if (request.ResolvePath is null) throw new ArgumentException("ResolvePath is required.", nameof(request)); + if (string.Equals(request.ParameterSetName, "Config", StringComparison.Ordinal)) + return PrepareFromConfig(request); + var moduleName = string.Equals(request.ParameterSetName, "Configuration", StringComparison.Ordinal) ? LegacySegmentAdapter.ResolveModuleNameFromLegacyConfiguration(request.Configuration) : request.ModuleName; @@ -84,7 +87,43 @@ public ModuleBuildPreparedContext Prepare(ModuleBuildPreparationRequest request) BasePathForScaffold = basePathForScaffold, UseLegacy = useLegacy, PipelineSpec = spec, - JsonOutputPath = request.JsonOnly ? ResolveJsonOutputPath(request, projectRoot) : null + JsonOutputPath = request.JsonOnly ? ResolveJsonOutputPath(request, projectRoot) : null, + ConfigLabel = useLegacy ? "dsl" : "cmdlet" + }; + } + + private ModuleBuildPreparedContext PrepareFromConfig(ModuleBuildPreparationRequest request) + { + if (string.IsNullOrWhiteSpace(request.ConfigPath)) + throw new PSArgumentException("ConfigPath is required."); + + var configFullPath = request.ResolvePath!(request.ConfigPath!); + if (!File.Exists(configFullPath)) + throw new FileNotFoundException($"Module build config file not found: {configFullPath}", configFullPath); + + var spec = ReadPipelineSpecJson(configFullPath); + ResolvePipelineSpecPaths(spec, configFullPath); + + if (spec.Build is null) + throw new InvalidOperationException("Module build config requires a Build section."); + if (string.IsNullOrWhiteSpace(spec.Build.Name)) + throw new InvalidOperationException("Module build config requires Build.Name."); + if (string.IsNullOrWhiteSpace(spec.Build.SourcePath)) + throw new InvalidOperationException("Module build config requires Build.SourcePath."); + + if (string.IsNullOrWhiteSpace(spec.Build.Version)) + spec.Build.Version = ResolveBaseVersion(spec.Build.SourcePath, spec.Build.Name); + + return new ModuleBuildPreparedContext + { + ModuleName = spec.Build.Name, + ProjectRoot = spec.Build.SourcePath, + BasePathForScaffold = null, + UseLegacy = false, + PipelineSpec = spec, + JsonOutputPath = request.JsonOnly ? ResolveJsonOutputPath(request, spec.Build.SourcePath) : null, + ConfigLabel = "json", + ConfigFilePath = configFullPath }; } @@ -111,6 +150,36 @@ public void WritePipelineSpecJson(ModulePipelineSpec spec, string jsonFullPath) File.WriteAllText(jsonFullPath, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); } + public ModulePipelineSpec ReadPipelineSpecJson(string jsonFullPath) + { + if (string.IsNullOrWhiteSpace(jsonFullPath)) throw new ArgumentException("Json path is required.", nameof(jsonFullPath)); + + try + { + var json = File.ReadAllText(jsonFullPath); + var spec = JsonSerializer.Deserialize(json, CreateJsonOptions()); + return spec ?? throw new InvalidOperationException("Parsed config is null."); + } + catch (Exception ex) when (ex is not FileNotFoundException) + { + throw new InvalidOperationException($"Failed to parse module build config '{jsonFullPath}'. {ex.Message}", ex); + } + } + + public void ResolvePipelineSpecPaths(ModulePipelineSpec spec, string configFullPath) + { + if (spec is null) throw new ArgumentNullException(nameof(spec)); + if (spec.Build is null) return; + + var baseDir = Path.GetDirectoryName(configFullPath) ?? Directory.GetCurrentDirectory(); + + spec.Build.SourcePath = ResolveConfigPath(baseDir, spec.Build.SourcePath); + spec.Build.StagingPath = ResolveConfigPathNullable(baseDir, spec.Build.StagingPath); + spec.Build.CsprojPath = ResolveConfigPathNullable(baseDir, spec.Build.CsprojPath); + if (spec.Diagnostics is not null && !string.IsNullOrWhiteSpace(spec.Diagnostics.BaselinePath)) + spec.Diagnostics.BaselinePath = ResolveConfigPath(baseDir, spec.Diagnostics.BaselinePath!); + } + private static string ResolveBaseVersion(string projectRoot, string moduleName, IReadOnlyList? segments) { var configuredVersion = ResolveConfiguredVersion(segments); @@ -192,6 +261,30 @@ private static string ResolveJsonOutputPath(ModuleBuildPreparationRequest reques return Path.Combine(projectRoot, "powerforge.json"); } + private static JsonSerializerOptions CreateJsonOptions() + { + var opts = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + opts.Converters.Add(new JsonStringEnumConverter()); + opts.Converters.Add(new ConfigurationSegmentJsonConverter()); + return opts; + } + + private static string ResolveConfigPath(string baseDir, string? path) + { + if (string.IsNullOrWhiteSpace(path)) return string.Empty; + return Path.GetFullPath(Path.IsPathRooted(path) ? path : Path.Combine(baseDir, path)); + } + + private static string? ResolveConfigPathNullable(string baseDir, string? path) + { + if (string.IsNullOrWhiteSpace(path)) return null; + return ResolveConfigPath(baseDir, path); + } + private static void PrepareSpecForJsonExport(ModulePipelineSpec spec, string jsonFullPath) { if (spec.Build is null) throw new ArgumentException("Spec.Build is required.", nameof(spec)); diff --git a/PowerForge.Tests/DotNetPublishPipelineRunnerBundleHardeningTests.cs b/PowerForge.Tests/DotNetPublishPipelineRunnerBundleHardeningTests.cs new file mode 100644 index 00000000..8e54e345 --- /dev/null +++ b/PowerForge.Tests/DotNetPublishPipelineRunnerBundleHardeningTests.cs @@ -0,0 +1,386 @@ +namespace PowerForge.Tests; + +public sealed class DotNetPublishPipelineRunnerBundleHardeningTests +{ + [Fact] + public void BuildBundle_RejectsGeneratedScriptTemplateOutsideProjectRoot() + { + var root = CreateTempRoot(); + var outsideRoot = Path.GetFullPath(Path.Combine(root, "..", Path.GetFileName(root) + "-outside")); + try + { + Directory.CreateDirectory(outsideRoot); + File.WriteAllText(Path.Combine(outsideRoot, "Install.ps1.template"), "{{CommandName}}"); + + var publishDir = Directory.CreateDirectory(Path.Combine(root, "publish", "app")).FullName; + File.WriteAllText(Path.Combine(publishDir, "App.exe"), "app"); + + var plan = CreatePlan( + root, + new DotNetPublishBundlePlan + { + Id = "package", + PrepareFromTarget = "app", + GeneratedScripts = new[] + { + new DotNetPublishBundleGeneratedScriptPlan + { + TemplatePath = Path.Combine("..", Path.GetFileName(outsideRoot), "Install.ps1.template"), + OutputPath = "Install.ps1", + Tokens = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["CommandName"] = "Install-App" + } + } + } + }); + + var ex = Assert.Throws(() => BuildBundle(plan, publishDir)); + Assert.Contains("outside ProjectRoot", ex.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + TryDelete(outsideRoot); + } + } + + [Fact] + public void BuildBundle_CopyItemThrowsWhenDestinationExistsAndClearDestinationDisabled() + { + var root = CreateTempRoot(); + try + { + var publishDir = Directory.CreateDirectory(Path.Combine(root, "publish", "app")).FullName; + File.WriteAllText(Path.Combine(publishDir, "App.exe"), "app"); + + var readmePath = Path.Combine(root, "Build", "README.package.md"); + Directory.CreateDirectory(Path.GetDirectoryName(readmePath)!); + File.WriteAllText(readmePath, "# Package"); + + var outputDir = Directory.CreateDirectory(Path.Combine(root, "Artifacts", "Bundles", "package")).FullName; + File.WriteAllText(Path.Combine(outputDir, "README.md"), "# Existing"); + + var plan = CreatePlan( + root, + new DotNetPublishBundlePlan + { + Id = "package", + PrepareFromTarget = "app", + ClearOutput = false, + CopyItems = new[] + { + new DotNetPublishBundleCopyItemPlan + { + SourcePath = "Build/README.package.md", + DestinationPath = "README.md", + ClearDestination = false + } + } + }); + + var ex = Assert.Throws(() => BuildBundle(plan, publishDir, outputDir)); + Assert.Contains("ClearDestination=false", ex.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void BuildBundle_CopyItemFileIntoExistingDirectoryKeepsFileNameWhenClearing() + { + var root = CreateTempRoot(); + try + { + var publishDir = Directory.CreateDirectory(Path.Combine(root, "publish", "app")).FullName; + File.WriteAllText(Path.Combine(publishDir, "App.exe"), "app"); + + var sourcePath = Path.Combine(root, "Build", "tool.dll"); + Directory.CreateDirectory(Path.GetDirectoryName(sourcePath)!); + File.WriteAllText(sourcePath, "tool"); + + var outputDir = Directory.CreateDirectory(Path.Combine(root, "Artifacts", "Bundles", "package")).FullName; + var existingDirectory = Directory.CreateDirectory(Path.Combine(outputDir, "Lib")).FullName; + File.WriteAllText(Path.Combine(existingDirectory, "old.txt"), "old"); + + var plan = CreatePlan( + root, + new DotNetPublishBundlePlan + { + Id = "package", + PrepareFromTarget = "app", + ClearOutput = false, + CopyItems = new[] + { + new DotNetPublishBundleCopyItemPlan + { + SourcePath = "Build/tool.dll", + DestinationPath = "Lib", + ClearDestination = true + } + } + }); + + BuildBundle(plan, publishDir, outputDir); + + Assert.True(Directory.Exists(Path.Combine(outputDir, "Lib"))); + Assert.True(File.Exists(Path.Combine(outputDir, "Lib", "tool.dll"))); + Assert.False(File.Exists(Path.Combine(outputDir, "Lib", "old.txt"))); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void BuildBundle_GeneratedScriptTokenValuesAreNotRenderedRecursively() + { + var root = CreateTempRoot(); + try + { + var publishDir = Directory.CreateDirectory(Path.Combine(root, "publish", "app")).FullName; + File.WriteAllText(Path.Combine(publishDir, "App.exe"), "app"); + + var outputDir = Path.Combine(root, "Artifacts", "Bundles", "package"); + var plan = CreatePlan( + root, + new DotNetPublishBundlePlan + { + Id = "package", + PrepareFromTarget = "app", + GeneratedScripts = new[] + { + new DotNetPublishBundleGeneratedScriptPlan + { + Template = "{{CommandName}}", + OutputPath = "Install.ps1", + Tokens = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["CommandName"] = "{{NestedCommand}}", + ["NestedCommand"] = "Install-App" + } + } + } + }); + + BuildBundle(plan, publishDir, outputDir); + + var script = File.ReadAllText(Path.Combine(outputDir, "Install.ps1")); + Assert.Contains("{{NestedCommand}}", script, StringComparison.Ordinal); + Assert.DoesNotContain("Install-App", script, StringComparison.Ordinal); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void BuildBundle_ModuleIncludeThrowsWhenDestinationExistsAndClearDestinationDisabled() + { + var root = CreateTempRoot(); + try + { + var publishDir = Directory.CreateDirectory(Path.Combine(root, "publish", "app")).FullName; + File.WriteAllText(Path.Combine(publishDir, "App.exe"), "app"); + + var moduleRoot = Directory.CreateDirectory(Path.Combine(root, "Artifacts", "Modules", "PowerTierBridge")).FullName; + File.WriteAllText(Path.Combine(moduleRoot, "PowerTierBridge.psd1"), "@{}"); + + var outputDir = Directory.CreateDirectory(Path.Combine(root, "Artifacts", "Bundles", "package")).FullName; + var existingModule = Directory.CreateDirectory(Path.Combine(outputDir, "Modules", "PowerTierBridge")).FullName; + File.WriteAllText(Path.Combine(existingModule, "PowerTierBridge.psd1"), "@{ Existing = $true }"); + + var plan = CreatePlan( + root, + new DotNetPublishBundlePlan + { + Id = "package", + PrepareFromTarget = "app", + ClearOutput = false, + ModuleIncludes = new[] + { + new DotNetPublishBundleModuleIncludePlan + { + ModuleName = "PowerTierBridge", + SourcePath = "Artifacts/Modules/{moduleName}", + DestinationPath = "Modules/{moduleName}", + ClearDestination = false + } + } + }); + + var ex = Assert.Throws(() => BuildBundle(plan, publishDir, outputDir)); + Assert.Contains("ClearDestination=false", ex.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void FindBundleSignTargets_MatchesFolderGlobAtRootAndNestedPaths() + { + var root = CreateTempRoot(); + try + { + var rootModule = Path.Combine(root, "Modules", "Root.dll"); + var nestedModule = Path.Combine(root, "Lib", "Modules", "Nested.dll"); + var other = Path.Combine(root, "Lib", "Other", "Other.dll"); + Directory.CreateDirectory(Path.GetDirectoryName(rootModule)!); + Directory.CreateDirectory(Path.GetDirectoryName(nestedModule)!); + Directory.CreateDirectory(Path.GetDirectoryName(other)!); + File.WriteAllText(rootModule, "root"); + File.WriteAllText(nestedModule, "nested"); + File.WriteAllText(other, "other"); + + var targets = DotNetPublishPipelineRunner.FindBundleSignTargets(root, new[] { "**/Modules/*.dll" }); + + Assert.Contains(rootModule, targets); + Assert.Contains(nestedModule, targets); + Assert.DoesNotContain(other, targets); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Plan_ThrowsWhenBundleIncludeTargetsFormCycle() + { + var root = CreateTempRoot(); + try + { + var app = CreateProject(root, "App/App.csproj"); + var worker = CreateProject(root, "Worker/Worker.csproj"); + var spec = new DotNetPublishSpec + { + DotNet = new DotNetPublishDotNetOptions + { + ProjectRoot = root, + Restore = false, + Build = false, + Runtimes = new[] { "win-x64" } + }, + Targets = new[] + { + CreateTarget("App", app), + CreateTarget("Worker", worker) + }, + Bundles = new[] + { + new DotNetPublishBundle + { + Id = "app-package", + PrepareFromTarget = "App", + Includes = new[] { new DotNetPublishBundleInclude { Target = "Worker" } } + }, + new DotNetPublishBundle + { + Id = "worker-package", + PrepareFromTarget = "Worker", + Includes = new[] { new DotNetPublishBundleInclude { Target = "App" } } + } + } + }; + + var ex = Assert.Throws(() => new DotNetPublishPipelineRunner(new NullLogger()).Plan(spec, null)); + Assert.Contains("dependency cycle", ex.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + + private static DotNetPublishPlan CreatePlan(string root, DotNetPublishBundlePlan bundle) + { + return new DotNetPublishPlan + { + ProjectRoot = root, + Bundles = new[] { bundle } + }; + } + + private static DotNetPublishArtefactResult BuildBundle( + DotNetPublishPlan plan, + string publishDir, + string? outputDir = null) + { + outputDir ??= Path.Combine(plan.ProjectRoot, "Artifacts", "Bundles", "package"); + var artefacts = new[] + { + new DotNetPublishArtefactResult + { + Category = DotNetPublishArtefactCategory.Publish, + Target = "app", + Framework = "net10.0", + Runtime = "win-x64", + Style = DotNetPublishStyle.PortableCompat, + OutputDir = publishDir, + PublishDir = publishDir + } + }; + var step = new DotNetPublishStep + { + Key = "bundle:package:app:net10.0:win-x64:PortableCompat", + Kind = DotNetPublishStepKind.Bundle, + BundleId = "package", + TargetName = "app", + Framework = "net10.0", + Runtime = "win-x64", + Style = DotNetPublishStyle.PortableCompat, + BundleOutputPath = outputDir + }; + + return new DotNetPublishPipelineRunner(new NullLogger()).BuildBundle(plan, artefacts, step); + } + + private static string CreateTempRoot() + { + var root = Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + return root; + } + + private static string CreateProject(string root, string relativePath) + { + var fullPath = Path.Combine(root, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, ""); + return fullPath; + } + + private static DotNetPublishTarget CreateTarget(string name, string projectPath) + { + return new DotNetPublishTarget + { + Name = name, + ProjectPath = projectPath, + Publish = new DotNetPublishPublishOptions + { + Framework = "net10.0", + Runtimes = new[] { "win-x64" }, + Styles = new[] { DotNetPublishStyle.PortableCompat } + } + }; + } + + private static void TryDelete(string path) + { + try + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + catch + { + // best effort + } + } +} diff --git a/PowerForge.Tests/DotNetPublishPipelineRunnerBundleTests.cs b/PowerForge.Tests/DotNetPublishPipelineRunnerBundleTests.cs index a75e5d42..2fa33c24 100644 --- a/PowerForge.Tests/DotNetPublishPipelineRunnerBundleTests.cs +++ b/PowerForge.Tests/DotNetPublishPipelineRunnerBundleTests.cs @@ -2,12 +2,36 @@ using System.IO; using System.IO.Compression; using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; using Xunit; namespace PowerForge.Tests; public sealed class DotNetPublishPipelineRunnerBundleTests { + [Fact] + public void ExamplePackageBundleMsi_DeserializesPackageLayoutPrimitives() + { + var repoRoot = RepoRootLocator.Find(); + var examplePath = Path.Combine(repoRoot, "Module", "Examples", "DotNetPublish", "Example.PackageBundleMsi.json"); + + Assert.True(File.Exists(examplePath), $"Example file not found: {examplePath}"); + + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + options.Converters.Add(new JsonStringEnumConverter()); + var spec = JsonSerializer.Deserialize(File.ReadAllText(examplePath), options); + + Assert.NotNull(spec); + var bundle = Assert.Single(spec.Bundles); + Assert.Equal("package", bundle.Id); + Assert.Equal("Service", bundle.PrimarySubdirectory); + Assert.Single(bundle.CopyItems); + Assert.Single(bundle.ModuleIncludes); + Assert.Single(bundle.GeneratedScripts); + Assert.Equal("package", Assert.Single(spec.Installers).PrepareFromBundleId); + } + [Fact] public void Plan_BindsInstallerPrepareFromBundleId_ToMsiPrepareStep() { @@ -47,6 +71,180 @@ public void Plan_BindsInstallerPrepareFromBundleId_ToMsiPrepareStep() } } + [Fact] + public void Plan_NormalizesBundlePackageLayoutPrimitives() + { + var root = CreateTempRoot(); + try + { + var app = CreateProject(root, "App/App.csproj"); + + var spec = CreateBaseSpec(root, app); + spec.Profile = "release"; + spec.Profiles = new[] + { + new DotNetPublishProfile + { + Name = "release", + Default = true, + Targets = new[] { "app" } + } + }; + spec.Bundles = new[] + { + new DotNetPublishBundle + { + Id = "package", + PrepareFromTarget = "app", + PrimarySubdirectory = " Service ", + CopyItems = new[] + { + new DotNetPublishBundleCopyItem + { + SourcePath = " Build/README.md ", + DestinationPath = " README.md " + } + }, + ModuleIncludes = new[] + { + new DotNetPublishBundleModuleInclude + { + ModuleName = " PowerTierBridge ", + SourcePath = " Artifacts/Modules/{moduleName} " + } + }, + GeneratedScripts = new[] + { + new DotNetPublishBundleGeneratedScript + { + Template = "{{CommandName}}", + OutputPath = " Scripts/Install-Service.ps1 ", + Tokens = new System.Collections.Generic.Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["CommandName"] = "Install-TierBridgeService" + } + } + } + } + }; + + var plan = new DotNetPublishPipelineRunner(new NullLogger()).Plan(spec, null); + var bundle = Assert.Single(plan.Bundles); + + Assert.Equal("Build/README.md", bundle.CopyItems[0].SourcePath); + Assert.Equal("README.md", bundle.CopyItems[0].DestinationPath); + Assert.Equal("Service", bundle.PrimarySubdirectory); + Assert.Equal("PowerTierBridge", bundle.ModuleIncludes[0].ModuleName); + Assert.Equal("Artifacts/Modules/{moduleName}", bundle.ModuleIncludes[0].SourcePath); + Assert.Equal("Modules/{moduleName}", bundle.ModuleIncludes[0].DestinationPath); + Assert.Equal("Scripts/Install-Service.ps1", bundle.GeneratedScripts[0].OutputPath); + Assert.Equal("Install-TierBridgeService", bundle.GeneratedScripts[0].Tokens["CommandName"]); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Plan_ResolvesBundlePostProcessSigningProfile() + { + var root = CreateTempRoot(); + try + { + var app = CreateProject(root, "App/App.csproj"); + + var spec = CreateBaseSpec(root, app); + spec.SigningProfiles = new System.Collections.Generic.Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["evotec"] = new DotNetPublishSignOptions + { + Enabled = true, + IncludeDlls = true, + Thumbprint = "ABC123", + TimestampUrl = "http://timestamp.example" + } + }; + spec.Bundles = new[] + { + new DotNetPublishBundle + { + Id = "package", + PrepareFromTarget = "app", + PostProcess = new DotNetPublishBundlePostProcessOptions + { + SignProfile = " evotec ", + SignPatterns = new[] { " **/*.ps1 ", "**/*.dll" }, + SignOverrides = new DotNetPublishSignPatch + { + Description = "TierBridge Package" + } + } + } + }; + + var plan = new DotNetPublishPipelineRunner(new NullLogger()).Plan(spec, null); + var postProcess = Assert.Single(plan.Bundles).PostProcess; + + Assert.NotNull(postProcess); + Assert.Equal(new[] { "**/*.ps1", "**/*.dll" }, postProcess!.SignPatterns); + Assert.NotNull(postProcess.Sign); + Assert.True(postProcess.Sign!.Enabled); + Assert.True(postProcess.Sign.IncludeDlls); + Assert.Equal("ABC123", postProcess.Sign.Thumbprint); + Assert.Equal("http://timestamp.example", postProcess.Sign.TimestampUrl); + Assert.Equal("TierBridge Package", postProcess.Sign.Description); + Assert.Null(postProcess.SignProfile); + Assert.Null(postProcess.SignOverrides); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void FindBundleSignTargets_MatchesNestedFilesAndExactPaths() + { + var root = CreateTempRoot(); + try + { + var bundleRoot = Directory.CreateDirectory(Path.Combine(root, "bundle")).FullName; + File.WriteAllText(Path.Combine(bundleRoot, "App.exe"), "app"); + File.WriteAllText(Path.Combine(bundleRoot, "RootLibrary.dll"), "dll"); + File.WriteAllText(Path.Combine(bundleRoot, "README.md"), "readme"); + + var scripts = Directory.CreateDirectory(Path.Combine(bundleRoot, "Scripts")).FullName; + File.WriteAllText(Path.Combine(scripts, "Install-TierBridgeService.ps1"), "script"); + + var module = Directory.CreateDirectory(Path.Combine(bundleRoot, "Modules", "PowerTierBridge")).FullName; + File.WriteAllText(Path.Combine(module, "PowerTierBridge.psm1"), "module"); + File.WriteAllText(Path.Combine(module, "PowerTierBridge.psd1"), "manifest"); + + var lib = Directory.CreateDirectory(Path.Combine(module, "Lib", "Core")).FullName; + File.WriteAllText(Path.Combine(lib, "TierBridge.PowerShell.dll"), "dll"); + File.WriteAllText(Path.Combine(lib, "createdump.exe"), "diag"); + + var targets = DotNetPublishPipelineRunner.FindBundleSignTargets( + bundleRoot, + new[] { "**/*.ps1", "**/*.psm1", "**/*.psd1", "**/*.dll", "App.exe", "createdump.exe" }); + + Assert.Equal(7, targets.Length); + Assert.Contains(targets, path => path.EndsWith("App.exe", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(targets, path => path.EndsWith("RootLibrary.dll", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(targets, path => path.EndsWith("Install-TierBridgeService.ps1", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(targets, path => path.EndsWith("PowerTierBridge.psm1", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(targets, path => path.EndsWith("PowerTierBridge.psd1", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(targets, path => path.EndsWith("TierBridge.PowerShell.dll", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(targets, path => path.EndsWith("createdump.exe", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(targets, path => path.EndsWith("README.md", StringComparison.OrdinalIgnoreCase)); + } + finally + { + TryDelete(root); + } + } + [Fact] public void BuildBundle_AppliesPostProcessArchiveDeleteAndMetadata() { @@ -158,6 +356,176 @@ public void BuildBundle_AppliesPostProcessArchiveDeleteAndMetadata() } } + [Fact] + public void BuildBundle_CopiesPackageItemsModulesAndGeneratedScripts() + { + var root = CreateTempRoot(); + try + { + var publishDir = Directory.CreateDirectory(Path.Combine(root, "publish", "app")).FullName; + File.WriteAllText(Path.Combine(publishDir, "App.exe"), "app"); + + var readmePath = Path.Combine(root, "Build", "README.package.md"); + Directory.CreateDirectory(Path.GetDirectoryName(readmePath)!); + File.WriteAllText(readmePath, "# Package"); + + var moduleRoot = Directory.CreateDirectory(Path.Combine(root, "Artifacts", "Modules", "PowerTierBridge")).FullName; + File.WriteAllText(Path.Combine(moduleRoot, "PowerTierBridge.psd1"), "@{}"); + File.WriteAllText(Path.Combine(moduleRoot, "PowerTierBridge.psm1"), ""); + + var plan = new DotNetPublishPlan + { + ProjectRoot = root, + Bundles = new[] + { + new DotNetPublishBundlePlan + { + Id = "package", + PrepareFromTarget = "app", + PrimarySubdirectory = "Service", + CopyItems = new[] + { + new DotNetPublishBundleCopyItemPlan + { + SourcePath = "Build/README.package.md", + DestinationPath = "README.md" + } + }, + ModuleIncludes = new[] + { + new DotNetPublishBundleModuleIncludePlan + { + ModuleName = "PowerTierBridge", + SourcePath = "Artifacts/Modules/{moduleName}", + DestinationPath = "Modules/{moduleName}" + } + }, + GeneratedScripts = new[] + { + new DotNetPublishBundleGeneratedScriptPlan + { + Template = "Import-Module \"$PSScriptRoot\\Modules\\{{ModuleName}}\\{{ModuleName}}.psd1\" -Force\r\n{{CommandName}}\r\n", + OutputPath = "Install-Service.ps1", + Tokens = new System.Collections.Generic.Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ModuleName"] = "PowerTierBridge", + ["CommandName"] = "Install-TierBridgeService" + } + } + } + } + } + }; + + var artefacts = new[] + { + new DotNetPublishArtefactResult + { + Category = DotNetPublishArtefactCategory.Publish, + Target = "app", + Framework = "net10.0", + Runtime = "win-x64", + Style = DotNetPublishStyle.PortableCompat, + OutputDir = publishDir, + PublishDir = publishDir + } + }; + + var outputDir = Path.Combine(root, "Artifacts", "Bundles", "package"); + var step = new DotNetPublishStep + { + Key = "bundle:package:app:net10.0:win-x64:PortableCompat", + Kind = DotNetPublishStepKind.Bundle, + BundleId = "package", + TargetName = "app", + Framework = "net10.0", + Runtime = "win-x64", + Style = DotNetPublishStyle.PortableCompat, + BundleOutputPath = outputDir + }; + + var result = InvokeBuildBundle(plan, artefacts, step); + + Assert.NotNull(result); + Assert.True(File.Exists(Path.Combine(outputDir, "Service", "App.exe"))); + Assert.False(File.Exists(Path.Combine(outputDir, "App.exe"))); + Assert.True(File.Exists(Path.Combine(outputDir, "README.md"))); + Assert.True(File.Exists(Path.Combine(outputDir, "Modules", "PowerTierBridge", "PowerTierBridge.psd1"))); + + var generatedScript = Path.Combine(outputDir, "Install-Service.ps1"); + Assert.True(File.Exists(generatedScript)); + var script = File.ReadAllText(generatedScript); + Assert.Contains("PowerTierBridge.psd1", script, StringComparison.Ordinal); + Assert.Contains("Install-TierBridgeService", script, StringComparison.Ordinal); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Plan_PublishesBundleIncludesBeforeBundleStep() + { + var root = CreateTempRoot(); + try + { + var app = CreateProject(root, "App/App.csproj"); + var cli = CreateProject(root, "Cli/Cli.csproj"); + + var spec = CreateBaseSpec(root, app); + spec.Targets = new[] + { + spec.Targets[0], + new DotNetPublishTarget + { + Name = "cli", + ProjectPath = cli, + Publish = new DotNetPublishPublishOptions + { + Framework = "net10.0", + Runtimes = new[] { "win-x64" }, + Styles = new[] { DotNetPublishStyle.AotSpeed }, + UseStaging = false + } + } + }; + spec.Bundles = new[] + { + new DotNetPublishBundle + { + Id = "package", + PrepareFromTarget = "app", + Includes = new[] + { + new DotNetPublishBundleInclude + { + Target = "cli", + Style = DotNetPublishStyle.AotSpeed, + Subdirectory = "CLI" + } + } + } + }; + + var plan = new DotNetPublishPipelineRunner(new NullLogger()).Plan(spec, null); + var keys = plan.Steps.Select(step => step.Key).ToArray(); + + var cliPublish = Array.FindIndex(keys, key => key.StartsWith("publish:cli:", StringComparison.OrdinalIgnoreCase)); + var appPublish = Array.FindIndex(keys, key => key.StartsWith("publish:app:", StringComparison.OrdinalIgnoreCase)); + var bundle = Array.FindIndex(keys, key => key.StartsWith("bundle:package:", StringComparison.OrdinalIgnoreCase)); + + Assert.True(cliPublish >= 0); + Assert.True(appPublish >= 0); + Assert.True(bundle > appPublish); + Assert.True(bundle > cliPublish); + } + finally + { + TryDelete(root); + } + } + [Fact] public void BuildBundle_RejectsPostProcessPathsOutsideBundleRoot() { diff --git a/PowerForge.Tests/DotNetPublishPipelineRunnerHookTests.cs b/PowerForge.Tests/DotNetPublishPipelineRunnerHookTests.cs new file mode 100644 index 00000000..96544a25 --- /dev/null +++ b/PowerForge.Tests/DotNetPublishPipelineRunnerHookTests.cs @@ -0,0 +1,393 @@ +namespace PowerForge.Tests; + +public sealed class DotNetPublishPipelineRunnerHookTests +{ + [Fact] + public void Plan_AddsCommandHooksAroundPublishAndBundleSteps() + { + var root = CreateTempRoot(); + try + { + var app = CreateProject(root, "App/App.csproj"); + var spec = new DotNetPublishSpec + { + Profile = "release", + Profiles = new[] + { + new DotNetPublishProfile + { + Name = "release", + Default = true, + Targets = new[] { "App" } + } + }, + DotNet = new DotNetPublishDotNetOptions + { + ProjectRoot = root, + Restore = false, + Build = false, + Runtimes = new[] { "win-x64" } + }, + Targets = new[] + { + new DotNetPublishTarget + { + Name = "App", + ProjectPath = app, + Publish = new DotNetPublishPublishOptions + { + Framework = "net10.0", + Runtimes = new[] { "win-x64" }, + Styles = new[] { DotNetPublishStyle.PortableCompat } + } + } + }, + Bundles = new[] + { + new DotNetPublishBundle + { + Id = "portable", + PrepareFromTarget = "App" + } + }, + Hooks = new[] + { + new DotNetPublishCommandHook + { + Id = "sync-catalog", + Phase = DotNetPublishCommandHookPhase.BeforeTargetPublish, + Command = "pwsh", + Arguments = new[] { "-NoProfile", "-Command", "exit 0" }, + Targets = new[] { "App" } + }, + new DotNetPublishCommandHook + { + Id = "bundle-summary", + Phase = DotNetPublishCommandHookPhase.AfterBundle, + Command = "pwsh", + Arguments = new[] { "-NoProfile", "-Command", "exit 0" } + } + } + }; + + var plan = new DotNetPublishPipelineRunner(new NullLogger()).Plan(spec, null); + var keys = plan.Steps.Select(step => step.Key).ToArray(); + + var beforePublish = Array.FindIndex(keys, key => key.StartsWith("hook:BeforeTargetPublish:sync-catalog", StringComparison.Ordinal)); + var publish = Array.FindIndex(keys, key => key.StartsWith("publish:App:", StringComparison.Ordinal)); + var bundle = Array.FindIndex(keys, key => key.StartsWith("bundle:portable:", StringComparison.Ordinal)); + var afterBundle = Array.FindIndex(keys, key => key.StartsWith("hook:AfterBundle:bundle-summary", StringComparison.Ordinal)); + + Assert.True(beforePublish >= 0); + Assert.True(publish > beforePublish); + Assert.True(bundle > publish); + Assert.True(afterBundle > bundle); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Plan_CommandHooksUseContextSpecificKeysAndDefaultTimeout() + { + var root = CreateTempRoot(); + try + { + var app = CreateProject(root, "App/App.csproj"); + var worker = CreateProject(root, "Worker/Worker.csproj"); + var spec = new DotNetPublishSpec + { + DotNet = new DotNetPublishDotNetOptions + { + ProjectRoot = root, + Restore = false, + Build = false, + Runtimes = new[] { "win-x64" } + }, + Targets = new[] + { + new DotNetPublishTarget + { + Name = "App", + ProjectPath = app, + Publish = new DotNetPublishPublishOptions + { + Framework = "net10.0", + Runtimes = new[] { "win-x64" }, + Styles = new[] { DotNetPublishStyle.PortableCompat } + } + }, + new DotNetPublishTarget + { + Name = "Worker", + ProjectPath = worker, + Publish = new DotNetPublishPublishOptions + { + Framework = "net10.0", + Runtimes = new[] { "win-x64" }, + Styles = new[] { DotNetPublishStyle.PortableCompat } + } + } + }, + Hooks = new[] + { + new DotNetPublishCommandHook + { + Id = "catalog", + Phase = DotNetPublishCommandHookPhase.BeforeTargetPublish, + Command = "dotnet" + } + } + }; + + var plan = new DotNetPublishPipelineRunner(new NullLogger()).Plan(spec, null); + var hookSteps = plan.Steps + .Where(step => step.Kind == DotNetPublishStepKind.CommandHook) + .ToArray(); + + Assert.Equal(2, hookSteps.Length); + Assert.Equal(2, hookSteps.Select(step => step.Key).Distinct(StringComparer.OrdinalIgnoreCase).Count()); + Assert.All(hookSteps, step => Assert.Equal(600, step.HookTimeoutSeconds)); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Plan_ThrowsWhenCommandHookIdsAreDuplicated() + { + var root = CreateTempRoot(); + try + { + var app = CreateProject(root, "App/App.csproj"); + var spec = CreateSpec(root, app); + spec.Hooks = new[] + { + new DotNetPublishCommandHook + { + Id = "catalog", + Phase = DotNetPublishCommandHookPhase.BeforeBuild, + Command = "dotnet" + }, + new DotNetPublishCommandHook + { + Id = " catalog ", + Phase = DotNetPublishCommandHookPhase.AfterTargetPublish, + Command = "dotnet" + } + }; + + var ex = Assert.Throws(() => new DotNetPublishPipelineRunner(new NullLogger()).Plan(spec, null)); + Assert.Contains("Duplicate hook ID", ex.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void Plan_ThrowsWhenCommandHookCommandIsMissing() + { + var root = CreateTempRoot(); + try + { + var app = CreateProject(root, "App/App.csproj"); + var spec = CreateSpec(root, app); + spec.Hooks = new[] + { + new DotNetPublishCommandHook + { + Id = "catalog", + Phase = DotNetPublishCommandHookPhase.BeforeBuild + } + }; + + var ex = Assert.Throws(() => new DotNetPublishPipelineRunner(new NullLogger()).Plan(spec, null)); + Assert.Contains("Hooks['catalog'].Command", ex.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void RunCommandHook_ExpandsArgumentsWorkingDirectoryAndEnvironment() + { + if (!CommandExists("pwsh")) + return; + + var root = CreateTempRoot(); + try + { + var outputPath = Path.Combine(root, "hook-output.txt"); + var step = new DotNetPublishStep + { + Key = "hook:BeforeBuild:write", + Kind = DotNetPublishStepKind.CommandHook, + HookId = "write", + HookPhase = DotNetPublishCommandHookPhase.BeforeBuild, + HookCommand = "pwsh", + HookArguments = new[] + { + "-NoLogo", + "-NoProfile", + "-Command", + "$value = \"target={0};rid={1};phase=$env:PF_HOOK_PHASE\" -f '{target}', '{rid}'; Set-Content -LiteralPath $env:PF_HOOK_OUTPUT -Value $value" + }, + HookEnvironment = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["PF_HOOK_OUTPUT"] = outputPath, + ["PF_HOOK_PHASE"] = "{phase}" + }, + TargetName = "App", + Runtime = "win-x64", + Framework = "net10.0", + Style = DotNetPublishStyle.PortableCompat, + HookTimeoutSeconds = 30, + HookRequired = true + }; + + new DotNetPublishPipelineRunner(new NullLogger()).RunCommandHook( + new DotNetPublishPlan + { + ProjectRoot = root, + Configuration = "Release" + }, + step); + + Assert.True(File.Exists(outputPath)); + var output = File.ReadAllText(outputPath); + Assert.Contains("target=App", output, StringComparison.Ordinal); + Assert.Contains("rid=win-x64", output, StringComparison.Ordinal); + Assert.Contains("phase=BeforeBuild", output, StringComparison.Ordinal); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void RunCommandHook_ReportsTimeoutExplicitly() + { + if (!CommandExists("pwsh")) + return; + + var root = CreateTempRoot(); + try + { + var step = new DotNetPublishStep + { + Key = "hook:BeforeBuild:slow", + Kind = DotNetPublishStepKind.CommandHook, + HookId = "slow", + HookPhase = DotNetPublishCommandHookPhase.BeforeBuild, + HookCommand = "pwsh", + HookArguments = new[] { "-NoLogo", "-NoProfile", "-Command", "[System.Threading.Thread]::Sleep([int]::MaxValue)" }, + HookTimeoutSeconds = 2, + HookRequired = true + }; + + var ex = Assert.ThrowsAny(() => + new DotNetPublishPipelineRunner(new NullLogger()).RunCommandHook( + new DotNetPublishPlan + { + ProjectRoot = root, + Configuration = "Release" + }, + step)); + + Assert.Contains("timed out after 2 seconds", ex.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + + private static string CreateProject(string root, string relativePath) + { + var fullPath = Path.Combine(root, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, ""); + return fullPath; + } + + private static DotNetPublishSpec CreateSpec(string root, string projectPath) + { + return new DotNetPublishSpec + { + DotNet = new DotNetPublishDotNetOptions + { + ProjectRoot = root, + Restore = false, + Build = false, + Runtimes = new[] { "win-x64" } + }, + Targets = new[] + { + new DotNetPublishTarget + { + Name = "App", + ProjectPath = projectPath, + Publish = new DotNetPublishPublishOptions + { + Framework = "net10.0", + Runtimes = new[] { "win-x64" }, + Styles = new[] { DotNetPublishStyle.PortableCompat } + } + } + } + }; + } + + private static string CreateTempRoot() + { + var root = Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + return root; + } + + private static void TryDelete(string path) + { + try + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + catch + { + // best effort + } + } + + private static bool CommandExists(string command) + { + var path = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrWhiteSpace(path)) + return false; + + var extensions = OperatingSystem.IsWindows() + ? (Environment.GetEnvironmentVariable("PATHEXT") ?? ".EXE;.CMD;.BAT") + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + : new[] { string.Empty }; + + foreach (var directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + foreach (var extension in extensions) + { + var candidate = Path.Combine(directory, command + extension); + if (File.Exists(candidate)) + return true; + } + } + + return false; + } +} diff --git a/PowerForge.Tests/DotNetPublishPipelineRunnerMsiPrepareTests.cs b/PowerForge.Tests/DotNetPublishPipelineRunnerMsiPrepareTests.cs index 82fbce7c..8a4311c4 100644 --- a/PowerForge.Tests/DotNetPublishPipelineRunnerMsiPrepareTests.cs +++ b/PowerForge.Tests/DotNetPublishPipelineRunnerMsiPrepareTests.cs @@ -283,6 +283,88 @@ public void PrepareMsiPackage_WritesHarvestFragment_WhenConfigured() } } + [Fact] + public void PrepareMsiPackage_ExcludesConfiguredHarvestPatterns() + { + var root = CreateTempRoot(); + try + { + var outputDir = Directory.CreateDirectory(Path.Combine(root, "publish", "svc")).FullName; + File.WriteAllText(Path.Combine(outputDir, "Svc.exe"), "dummy"); + File.WriteAllText(Path.Combine(outputDir, "Install-Service.ps1"), "install"); + Directory.CreateDirectory(Path.Combine(outputDir, "data")); + File.WriteAllText(Path.Combine(outputDir, "data", "settings.json"), "{ }"); + Directory.CreateDirectory(Path.Combine(outputDir, "data", "deep")); + File.WriteAllText(Path.Combine(outputDir, "data", "deep", "symbols.pdb"), "symbols"); + File.WriteAllText(Path.Combine(outputDir, "data", "deep", "createdump.exe"), "diag"); + + var stagingPath = Path.Combine(root, "Artifacts", "Msi", "svc.msi", "payload"); + var manifestPath = Path.Combine(root, "Artifacts", "Msi", "svc.msi", "prepare.manifest.json"); + var harvestPath = Path.Combine(root, "Artifacts", "Msi", "svc.msi", "harvest.wxs"); + + var step = new DotNetPublishStep + { + Key = "msi.prepare:svc.msi:svc:net10.0:win-x64:Portable", + Kind = DotNetPublishStepKind.MsiPrepare, + Title = "MSI prepare", + InstallerId = "svc.msi", + TargetName = "svc", + Framework = "net10.0", + Runtime = "win-x64", + Style = DotNetPublishStyle.Portable, + StagingPath = stagingPath, + ManifestPath = manifestPath, + HarvestPath = harvestPath, + HarvestDirectoryRefId = "INSTALLFOLDER", + HarvestComponentGroupId = "Harvest_svc_msi" + }; + + var plan = new DotNetPublishPlan + { + ProjectRoot = root, + AllowOutputOutsideProjectRoot = false, + Installers = new[] + { + new DotNetPublishInstallerPlan + { + Id = "svc.msi", + HarvestExcludePatterns = new[] { "Svc.exe", "**/Install-Service.ps1", "**/*.pdb", "createdump.exe" } + } + } + }; + + var artefacts = new[] + { + new DotNetPublishArtefactResult + { + Target = "svc", + Framework = "net10.0", + Runtime = "win-x64", + Style = DotNetPublishStyle.Portable, + OutputDir = outputDir + } + }; + + var runner = new DotNetPublishPipelineRunner(new NullLogger()); + var method = typeof(DotNetPublishPipelineRunner).GetMethod("PrepareMsiPackage", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + var result = method!.Invoke(runner, new object[] { plan, artefacts, step }) as DotNetPublishMsiPrepareResult; + Assert.NotNull(result); + + var wxs = File.ReadAllText(result!.HarvestPath!); + Assert.DoesNotContain("Svc.exe", wxs, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Install-Service.ps1", wxs, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("symbols.pdb", wxs, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("createdump.exe", wxs, StringComparison.OrdinalIgnoreCase); + Assert.Contains("settings.json", wxs, StringComparison.OrdinalIgnoreCase); + } + finally + { + TryDelete(root); + } + } + private static DotNetPublishSpec CreateBaseSpec(string root, string projectPath) { return new DotNetPublishSpec diff --git a/PowerForge.Tests/DotNetPublishPreparationServiceTests.cs b/PowerForge.Tests/DotNetPublishPreparationServiceTests.cs index d421855c..5be4e081 100644 --- a/PowerForge.Tests/DotNetPublishPreparationServiceTests.cs +++ b/PowerForge.Tests/DotNetPublishPreparationServiceTests.cs @@ -113,4 +113,42 @@ public void Prepare_from_settings_defaults_json_path_to_current_path() try { root.Delete(recursive: true); } catch { } } } + + [Fact] + public void Prepare_from_config_applies_project_root_override() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "pf-dotnet-publish-root-" + Guid.NewGuid().ToString("N"))); + + try + { + var repoRoot = Directory.CreateDirectory(Path.Combine(root.FullName, "repo")); + var configPath = Path.Combine(root.FullName, "Build", "publish.json"); + Directory.CreateDirectory(Path.GetDirectoryName(configPath)!); + File.WriteAllText(configPath, """ +{ + "dotNet": { + "projectRoot": ".." + }, + "targets": [] +} +"""); + + var request = new DotNetPublishPreparationRequest + { + ParameterSetName = "Config", + CurrentPath = root.FullName, + ResolvePath = path => Path.IsPathRooted(path) ? path : Path.GetFullPath(Path.Combine(root.FullName, path)), + ConfigPath = configPath, + ProjectRoot = repoRoot.FullName + }; + + var context = new DotNetPublishPreparationService(new NullLogger()).Prepare(request); + + Assert.Equal(repoRoot.FullName, context.Spec.DotNet.ProjectRoot); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } } diff --git a/PowerForge.Tests/ExportDetectorTests.cs b/PowerForge.Tests/ExportDetectorTests.cs index c416ac1e..07ea6834 100644 --- a/PowerForge.Tests/ExportDetectorTests.cs +++ b/PowerForge.Tests/ExportDetectorTests.cs @@ -1,5 +1,6 @@ using System.Management.Automation; using System.Text; +using PSPublishModule; namespace PowerForge.Tests; @@ -24,6 +25,35 @@ public void DetectBinaryAliases_finds_AliasAttribute() Assert.Contains("Get-ExampleAlias", aliases); } + [Fact] + public void DetectBinaryCmdlets_finds_PSPublishModule_plugin_and_bundle_cmdlets() + { + var path = typeof(InvokePowerForgePluginExportCommand).Assembly.Location; + var cmdlets = BinaryExportDetector.DetectBinaryCmdlets(new[] { path }); + + Assert.Contains("Invoke-PowerForgeBundlePostProcess", cmdlets); + Assert.Contains("Invoke-PowerForgePluginExport", cmdlets); + Assert.Contains("Invoke-PowerForgePluginPack", cmdlets); + } + + [Fact] + public void GeneratedModuleFiles_export_PSPublishModule_plugin_and_bundle_cmdlets() + { + var repoRoot = RepoRootLocator.Find(); + var manifestPath = Path.Combine(repoRoot, "Module", "PSPublishModule.psd1"); + var bootstrapperPath = Path.Combine(repoRoot, "Module", "PSPublishModule.psm1"); + + var exports = ModuleManifestExportReader.ReadExports(manifestPath); + var bootstrapper = File.ReadAllText(bootstrapperPath); + + Assert.Contains("Invoke-PowerForgeBundlePostProcess", exports.Cmdlets); + Assert.Contains("Invoke-PowerForgePluginExport", exports.Cmdlets); + Assert.Contains("Invoke-PowerForgePluginPack", exports.Cmdlets); + Assert.Contains("Invoke-PowerForgeBundlePostProcess", bootstrapper, StringComparison.Ordinal); + Assert.Contains("Invoke-PowerForgePluginExport", bootstrapper, StringComparison.Ordinal); + Assert.Contains("Invoke-PowerForgePluginPack", bootstrapper, StringComparison.Ordinal); + } + [Fact] public void DetectScriptFunctions_ignores_nested_helper_functions() { @@ -58,4 +88,5 @@ function Write-DeliveryError { private sealed class GetExampleCommand : PSCmdlet { } + } diff --git a/PowerForge.Tests/LineEndingsNormalizerTests.cs b/PowerForge.Tests/LineEndingsNormalizerTests.cs new file mode 100644 index 00000000..e029c688 --- /dev/null +++ b/PowerForge.Tests/LineEndingsNormalizerTests.cs @@ -0,0 +1,55 @@ +using System.Text; + +namespace PowerForge.Tests; + +public sealed class LineEndingsNormalizerTests +{ + [Fact] + public void NormalizeFile_ToCrLf_ConvertsBareCrAndLf() + { + var path = Path.Combine(Path.GetTempPath(), $"powerforge-lineendings-{Guid.NewGuid():N}.ps1"); + try + { + File.WriteAllText(path, "one\ntwo\rthree\r\nfour", new UTF8Encoding(false)); + + var result = new LineEndingsNormalizer().NormalizeFile( + path, + new NormalizationOptions(LineEnding.CRLF, ensureUtf8Bom: false)); + + var text = File.ReadAllText(path, new UTF8Encoding(false)); + + Assert.True(result.Changed); + Assert.Equal("one\r\ntwo\r\nthree\r\nfour", text); + Assert.DoesNotContain("\rt", text); + Assert.DoesNotContain("e\nf", text); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void NormalizeFile_ToLf_ConvertsBareCrAndCrLf() + { + var path = Path.Combine(Path.GetTempPath(), $"powerforge-lineendings-{Guid.NewGuid():N}.ps1"); + try + { + File.WriteAllText(path, "one\r\ntwo\rthree", new UTF8Encoding(false)); + + var result = new LineEndingsNormalizer().NormalizeFile( + path, + new NormalizationOptions(LineEnding.LF, ensureUtf8Bom: false)); + + var text = File.ReadAllText(path, new UTF8Encoding(false)); + + Assert.True(result.Changed); + Assert.Equal("one\ntwo\nthree", text); + Assert.DoesNotContain('\r', text); + } + finally + { + File.Delete(path); + } + } +} diff --git a/PowerForge.Tests/ModuleBuildPreparationServiceTests.cs b/PowerForge.Tests/ModuleBuildPreparationServiceTests.cs index e9fb7565..cfb7ac69 100644 --- a/PowerForge.Tests/ModuleBuildPreparationServiceTests.cs +++ b/PowerForge.Tests/ModuleBuildPreparationServiceTests.cs @@ -317,6 +317,57 @@ public void WritePipelineSpecJson_round_trips_pipeline_plan_without_losing_publi } } + [Fact] + public void Prepare_from_config_loads_pipeline_json_and_resolves_paths() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "pf-modulebuild-config-json-" + Guid.NewGuid().ToString("N"))); + + try + { + var moduleRoot = Directory.CreateDirectory(Path.Combine(root.FullName, "Module")); + File.WriteAllText(Path.Combine(moduleRoot.FullName, "PowerTierBridge.psd1"), "@{ ModuleVersion = '4.0.2' }"); + + var configDir = Directory.CreateDirectory(Path.Combine(root.FullName, "Build")); + var configPath = Path.Combine(configDir.FullName, "module.build.json"); + File.WriteAllText(configPath, """ +{ + "Build": { + "Name": "PowerTierBridge", + "SourcePath": "../Module", + "StagingPath": "../Build/Artifacts/Module/Staging", + "CsprojPath": "../TierBridge.PowerShell/TierBridge.PowerShell.csproj", + "Version": "4.0.X" + }, + "Diagnostics": { + "BaselinePath": ".powerforge/module-baseline.json" + } +} +"""); + + var prepared = new ModuleBuildPreparationService().Prepare(new ModuleBuildPreparationRequest + { + ParameterSetName = "Config", + ConfigPath = configPath, + CurrentPath = root.FullName, + ResolvePath = path => Path.IsPathRooted(path) ? path : Path.GetFullPath(Path.Combine(root.FullName, path)) + }); + + Assert.Equal("PowerTierBridge", prepared.ModuleName); + Assert.Equal(moduleRoot.FullName, prepared.ProjectRoot); + Assert.False(prepared.UseLegacy); + Assert.Null(prepared.BasePathForScaffold); + Assert.Equal("json", prepared.ConfigLabel); + Assert.Equal(configPath, prepared.ConfigFilePath); + Assert.Equal(Path.Combine(root.FullName, "Build", "Artifacts", "Module", "Staging"), prepared.PipelineSpec.Build.StagingPath); + Assert.Equal(Path.Combine(root.FullName, "TierBridge.PowerShell", "TierBridge.PowerShell.csproj"), prepared.PipelineSpec.Build.CsprojPath); + Assert.Equal(Path.Combine(configDir.FullName, ".powerforge", "module-baseline.json"), prepared.PipelineSpec.Diagnostics.BaselinePath); + } + finally + { + try { root.Delete(recursive: true); } catch { } + } + } + private static JsonSerializerOptions CreateJsonOptions() { var options = new JsonSerializerOptions diff --git a/PowerForge.Tests/PowerForgePluginCatalogServiceTests.cs b/PowerForge.Tests/PowerForgePluginCatalogServiceTests.cs index 507f29b9..c349f7b8 100644 --- a/PowerForge.Tests/PowerForgePluginCatalogServiceTests.cs +++ b/PowerForge.Tests/PowerForgePluginCatalogServiceTests.cs @@ -6,6 +6,36 @@ namespace PowerForge.Tests; public sealed class PowerForgePluginCatalogServiceTests { + [Fact] + public void ExampleCatalog_deserializes_to_plugin_catalog_spec() + { + var repoRoot = RepoRootLocator.Find(); + var examplePath = Path.Combine(repoRoot, "Module", "Examples", "PluginCatalog", "powerforge.plugins.json"); + + Assert.True(File.Exists(examplePath), $"Plugin catalog example not found: {examplePath}"); + + var spec = JsonSerializer.Deserialize( + File.ReadAllText(examplePath), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + Assert.NotNull(spec); + Assert.Equal("Release", spec.Configuration); + Assert.Equal("../../..", spec.ProjectRoot); + Assert.Collection( + spec.Catalog, + first => + { + Assert.Equal("sample-public-plugin", first.Id); + Assert.Contains("public", first.Groups); + Assert.Equal("plugin.manifest.json", first.Manifest?.FileName); + }, + second => + { + Assert.Equal("sample-private-plugin", second.Id); + Assert.False(second.Manifest?.Enabled); + }); + } + [Fact] public void PlanFolderExport_SelectsGroupsAndResolvesPreferredFramework() { @@ -447,4 +477,5 @@ private static void TryDelete(string path) else if (File.Exists(path)) File.Delete(path); } + } diff --git a/PowerForge.Tests/RepoRootLocator.cs b/PowerForge.Tests/RepoRootLocator.cs new file mode 100644 index 00000000..2ec3e9b7 --- /dev/null +++ b/PowerForge.Tests/RepoRootLocator.cs @@ -0,0 +1,21 @@ +namespace PowerForge.Tests; + +internal static class RepoRootLocator +{ + private const int MaxSearchDepth = 12; + + public static string Find() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + for (var i = 0; i < MaxSearchDepth && current is not null; i++) + { + var marker = Path.Combine(current.FullName, "PowerForge", "PowerForge.csproj"); + if (File.Exists(marker)) + return current.FullName; + + current = current.Parent; + } + + throw new DirectoryNotFoundException("Unable to locate repository root for PowerForge tests."); + } +} diff --git a/PowerForge/Models/DotNetPublish/DotNetPublishEnums.cs b/PowerForge/Models/DotNetPublish/DotNetPublishEnums.cs index 0c0e1c31..ca0ac856 100644 --- a/PowerForge/Models/DotNetPublish/DotNetPublishEnums.cs +++ b/PowerForge/Models/DotNetPublish/DotNetPublishEnums.cs @@ -143,11 +143,32 @@ public enum DotNetPublishBaselineMode Update } +/// +/// Phase where a configured command hook runs within the dotnet publish pipeline. +/// +public enum DotNetPublishCommandHookPhase +{ + /// Run before restore steps. + BeforeRestore, + /// Run before build steps. + BeforeBuild, + /// Run before each matching target publish step. + BeforeTargetPublish, + /// Run after each matching target publish step. + AfterTargetPublish, + /// Run before each matching bundle step. + BeforeBundle, + /// Run after each matching bundle step. + AfterBundle +} + /// /// Kind of step executed by the dotnet publish pipeline. /// public enum DotNetPublishStepKind { + /// Run a configured command hook. + CommandHook, /// Restore NuGet packages. Restore, /// Clean build outputs. diff --git a/PowerForge/Models/DotNetPublish/DotNetPublishPlan.cs b/PowerForge/Models/DotNetPublish/DotNetPublishPlan.cs index 269469c5..9e39fc1f 100644 --- a/PowerForge/Models/DotNetPublish/DotNetPublishPlan.cs +++ b/PowerForge/Models/DotNetPublish/DotNetPublishPlan.cs @@ -152,6 +152,9 @@ public sealed class DotNetPublishInstallerPlan /// Optional WiX component group ID template. public string? HarvestComponentGroupId { get; set; } + /// Optional wildcard patterns excluded from generated WiX harvest output. + public string[] HarvestExcludePatterns { get; set; } = Array.Empty(); + /// Optional MSI version policy used by build steps. public DotNetPublishMsiVersionOptions? Versioning { get; set; } @@ -233,6 +236,9 @@ public sealed class DotNetPublishBundlePlan /// Optional resolved bundle output path template. public string? OutputPath { get; set; } + /// Optional bundle subdirectory for the primary source target. + public string? PrimarySubdirectory { get; set; } + /// When true, clears the bundle output directory before composition. public bool ClearOutput { get; set; } = true; @@ -248,6 +254,15 @@ public sealed class DotNetPublishBundlePlan /// Additional published target includes. public DotNetPublishBundleIncludePlan[] Includes { get; set; } = Array.Empty(); + /// Additional file or directory copy items. + public DotNetPublishBundleCopyItemPlan[] CopyItems { get; set; } = Array.Empty(); + + /// PowerShell module payloads copied into the bundle. + public DotNetPublishBundleModuleIncludePlan[] ModuleIncludes { get; set; } = Array.Empty(); + + /// Scripts generated from templates into the bundle. + public DotNetPublishBundleGeneratedScriptPlan[] GeneratedScripts { get; set; } = Array.Empty(); + /// Bundle post-copy scripts. public DotNetPublishBundleScriptPlan[] Scripts { get; set; } = Array.Empty(); @@ -279,6 +294,69 @@ public sealed class DotNetPublishBundleIncludePlan public bool Required { get; set; } = true; } +/// +/// Resolved file or directory copy item for a bundle. +/// +public sealed class DotNetPublishBundleCopyItemPlan +{ + /// Source file or directory path template. + public string SourcePath { get; set; } = string.Empty; + + /// Destination path template under the bundle output root. + public string DestinationPath { get; set; } = string.Empty; + + /// When true, missing sources fail the bundle step. + public bool Required { get; set; } = true; + + /// When true, clears an existing destination before copy. + public bool ClearDestination { get; set; } = true; +} + +/// +/// Resolved PowerShell module payload include for a bundle. +/// +public sealed class DotNetPublishBundleModuleIncludePlan +{ + /// Logical module name. + public string ModuleName { get; set; } = string.Empty; + + /// Source module directory path template. + public string SourcePath { get; set; } = string.Empty; + + /// Destination path template under the bundle output root. + public string DestinationPath { get; set; } = string.Empty; + + /// When true, missing sources fail the bundle step. + public bool Required { get; set; } = true; + + /// When true, clears an existing destination before copy. + public bool ClearDestination { get; set; } = true; +} + +/// +/// Resolved template-generated script for a bundle. +/// +public sealed class DotNetPublishBundleGeneratedScriptPlan +{ + /// Optional template file path template. + public string? TemplatePath { get; set; } + + /// Optional inline template content. + public string? Template { get; set; } + + /// Output path template under the bundle output root. + public string OutputPath { get; set; } = string.Empty; + + /// Template token values. + public Dictionary Tokens { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + /// When true, replaces an existing output file. + public bool Overwrite { get; set; } = true; + + /// Optional signing options for the generated script. + public DotNetPublishSignOptions? Sign { get; set; } +} + /// /// Resolved PowerShell hook for bundle composition. /// @@ -436,4 +514,28 @@ public sealed class DotNetPublishStep /// Optional resolved Store package output path for Store build steps. public string? StorePackageOutputPath { get; set; } + + /// Optional command hook identifier. + public string? HookId { get; set; } + + /// Optional command hook phase. + public DotNetPublishCommandHookPhase? HookPhase { get; set; } + + /// Optional command hook executable or script. + public string? HookCommand { get; set; } + + /// Optional command hook arguments. + public string[] HookArguments { get; set; } = Array.Empty(); + + /// Optional command hook working directory. + public string? HookWorkingDirectory { get; set; } + + /// Optional command hook environment variables. + public Dictionary HookEnvironment { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + /// Optional command hook timeout in seconds. + public int HookTimeoutSeconds { get; set; } = DotNetPublishCommandHook.DefaultTimeoutSeconds; + + /// When true, command hook failures fail the publish run. + public bool HookRequired { get; set; } = true; } diff --git a/PowerForge/Models/DotNetPublish/DotNetPublishSpec.cs b/PowerForge/Models/DotNetPublish/DotNetPublishSpec.cs index 10a82383..f111cd0f 100644 --- a/PowerForge/Models/DotNetPublish/DotNetPublishSpec.cs +++ b/PowerForge/Models/DotNetPublish/DotNetPublishSpec.cs @@ -72,6 +72,11 @@ public sealed class DotNetPublishSpec /// public DotNetPublishBenchmarkGate[] BenchmarkGates { get; set; } = Array.Empty(); + /// + /// Optional command hooks executed at fixed publish phases. + /// + public DotNetPublishCommandHook[] Hooks { get; set; } = Array.Empty(); + /// /// Optional manifest output configuration. /// @@ -194,6 +199,12 @@ public sealed class DotNetPublishInstaller /// public string? HarvestComponentGroupId { get; set; } + /// + /// Optional wildcard patterns excluded from generated WiX harvest output. + /// Patterns match paths relative to the MSI staging root using forward slashes. + /// + public string[] HarvestExcludePatterns { get; set; } = Array.Empty(); + /// /// Optional MSI versioning policy used by msi.build. /// @@ -329,6 +340,12 @@ public sealed class DotNetPublishBundle /// public string? OutputPath { get; set; } + /// + /// Optional subdirectory under the bundle output root where the primary publish target should be copied. + /// When omitted, the primary target is copied into the bundle root. + /// + public string? PrimarySubdirectory { get; set; } + /// /// When true, clears the bundle output directory before composing files. Default: true. /// @@ -355,6 +372,21 @@ public sealed class DotNetPublishBundle /// public DotNetPublishBundleInclude[] Includes { get; set; } = Array.Empty(); + /// + /// Optional file or directory items copied into the bundle after publish-target includes. + /// + public DotNetPublishBundleCopyItem[] CopyItems { get; set; } = Array.Empty(); + + /// + /// Optional built PowerShell module payloads copied into the bundle, usually under Modules/{moduleName}. + /// + public DotNetPublishBundleModuleInclude[] ModuleIncludes { get; set; } = Array.Empty(); + + /// + /// Optional scripts generated from templates into the bundle after copy operations. + /// + public DotNetPublishBundleGeneratedScript[] GeneratedScripts { get; set; } = Array.Empty(); + /// /// Optional PowerShell scripts executed after the bundle contents are copied. /// @@ -401,6 +433,75 @@ public sealed class DotNetPublishBundleInclude public bool Required { get; set; } = true; } +/// +/// File or directory copied into a bundle from a path outside the publish target output. +/// +public sealed class DotNetPublishBundleCopyItem +{ + /// Source file or directory path. Relative paths resolve against project root and support bundle tokens. + public string SourcePath { get; set; } = string.Empty; + + /// Destination path under the bundle output root. Supports bundle tokens. + public string DestinationPath { get; set; } = string.Empty; + + /// When true, missing sources fail the bundle step. Default: true. + public bool Required { get; set; } = true; + + /// When true, clears an existing destination file/directory before copy. Default: true. + public bool ClearDestination { get; set; } = true; +} + +/// +/// Built PowerShell module payload copied into a bundle. +/// +public sealed class DotNetPublishBundleModuleInclude +{ + /// Logical module name. Used by default destination and template tokens. + public string ModuleName { get; set; } = string.Empty; + + /// Source module directory path, preferably a PowerForge module build artefact output. + public string SourcePath { get; set; } = string.Empty; + + /// Optional destination path under the bundle output root. Default: Modules/{moduleName}. + public string? DestinationPath { get; set; } + + /// When true, missing sources fail the bundle step. Default: true. + public bool Required { get; set; } = true; + + /// When true, clears an existing destination directory before copy. Default: true. + public bool ClearDestination { get; set; } = true; +} + +/// +/// Script generated from inline or file-based template content into a bundle. +/// +public sealed class DotNetPublishBundleGeneratedScript +{ + /// Optional template file path resolved relative to project root. Supports bundle tokens. + public string? TemplatePath { get; set; } + + /// Optional inline template content. Used when is not set. + public string? Template { get; set; } + + /// Output path under the bundle output root. Supports bundle tokens. + public string OutputPath { get; set; } = string.Empty; + + /// Template token values. Values support bundle tokens before template rendering. + public Dictionary? Tokens { get; set; } + + /// When true, replaces an existing output file. Default: true. + public bool Overwrite { get; set; } = true; + + /// Optional named signing profile reference used when is not set. + public string? SignProfile { get; set; } + + /// Optional signing options for the generated script. + public DotNetPublishSignOptions? Sign { get; set; } + + /// Optional partial overrides applied on top of . + public DotNetPublishSignPatch? SignOverrides { get; set; } +} + /// /// PowerShell script executed as part of bundle composition. /// @@ -454,6 +555,21 @@ public sealed class DotNetPublishBundlePostProcessOptions /// public string[] DeletePatterns { get; set; } = Array.Empty(); + /// + /// Optional wildcard patterns for files to sign relative to bundle root. + /// When omitted and signing is enabled, bundle signing targets executables plus DLLs when is true. + /// + public string[] SignPatterns { get; set; } = Array.Empty(); + + /// Optional named signing profile reference used when is not set. + public string? SignProfile { get; set; } + + /// Optional signing options applied to bundle files matched by . + public DotNetPublishSignOptions? Sign { get; set; } + + /// Optional partial overrides applied on top of . + public DotNetPublishSignPatch? SignOverrides { get; set; } + /// /// Optional metadata manifest emitted into the bundle after post-processing. /// @@ -675,6 +791,51 @@ public sealed class DotNetPublishBenchmarkMetric public bool Required { get; set; } = true; } +/// +/// Command hook executed at a fixed phase of the dotnet publish pipeline. +/// +public sealed class DotNetPublishCommandHook +{ + /// Default command hook timeout in seconds. + public const int DefaultTimeoutSeconds = 600; + + /// Stable hook identifier used in plan step keys. + public string Id { get; set; } = string.Empty; + + /// Pipeline phase where the hook runs. + public DotNetPublishCommandHookPhase Phase { get; set; } + + /// Executable or script command. Relative paths resolve against project root and support hook tokens. + public string Command { get; set; } = string.Empty; + + /// Command arguments. Values support hook tokens. + public string[] Arguments { get; set; } = Array.Empty(); + + /// Optional working directory. Relative paths resolve against project root and support hook tokens. + public string? WorkingDirectory { get; set; } + + /// Optional environment variables. Values support hook tokens. + public Dictionary? Environment { get; set; } + + /// Maximum command execution time in seconds. Default: 600. + public int TimeoutSeconds { get; set; } = DefaultTimeoutSeconds; + + /// When true, a non-zero exit code fails the publish run. Default: true. + public bool Required { get; set; } = true; + + /// Optional target-name filter for target/bundle phases. + public string[] Targets { get; set; } = Array.Empty(); + + /// Optional runtime filter for target/bundle phases. + public string[] Runtimes { get; set; } = Array.Empty(); + + /// Optional framework filter for target/bundle phases. + public string[] Frameworks { get; set; } = Array.Empty(); + + /// Optional publish-style filter for target/bundle phases. + public DotNetPublishStyle[] Styles { get; set; } = Array.Empty(); +} + /// /// Global matrix defaults and filters for target expansion. /// diff --git a/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs b/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs index ad246741..af47f532 100644 --- a/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs +++ b/PowerForge/Services/DotNetPublishPipelineRunner.Bundle.cs @@ -1,4 +1,5 @@ using System.IO.Compression; +using System.Text; namespace PowerForge; @@ -59,7 +60,12 @@ internal DotNetPublishArtefactResult BuildBundle( } Directory.CreateDirectory(outputDir); - DirectoryCopy(sourceArtefact.OutputDir, outputDir); + var primaryDestination = string.IsNullOrWhiteSpace(bundle.PrimarySubdirectory) + ? outputDir + : ResolvePath(outputDir, bundle.PrimarySubdirectory!); + EnsurePathWithinRoot(outputDir, primaryDestination, $"Bundle '{bundleId}' primary destination"); + Directory.CreateDirectory(primaryDestination); + DirectoryCopy(sourceArtefact.OutputDir, primaryDestination); foreach (var include in bundle.Includes ?? Array.Empty()) { @@ -106,6 +112,7 @@ internal DotNetPublishArtefactResult BuildBundle( ["projectRoot"] = plan.ProjectRoot, ["output"] = outputDir, ["sourceOutput"] = sourceArtefact.OutputDir, + ["primaryOutput"] = primaryDestination, ["zip"] = step.BundleZipPath ?? string.Empty }; @@ -115,6 +122,9 @@ internal DotNetPublishArtefactResult BuildBundle( tokens["keepDocs"] = (sourceTargetPlan?.Publish?.KeepDocs ?? false).ToString(); tokens["signEnabled"] = (sourceTargetPlan?.Publish?.Sign?.Enabled ?? false).ToString(); + CopyBundleItems(plan, bundle, outputDir, tokens); + CopyBundleModules(plan, bundle, outputDir, tokens); + GenerateBundleScripts(plan, bundle, outputDir, tokens); RunBundleScripts(plan, bundle, tokens); if (bundle.PostProcess is not null) { @@ -133,6 +143,8 @@ internal DotNetPublishArtefactResult BuildBundle( SourceOutputPath = sourceArtefact.OutputDir, PostProcess = bundle.PostProcess }); + + SignBundlePostProcessFiles(plan, bundle, outputDir); } string? zipPath = null; @@ -213,6 +225,144 @@ private void RunBundleScripts( } } + private void CopyBundleItems( + DotNetPublishPlan plan, + DotNetPublishBundlePlan bundle, + string outputDir, + IReadOnlyDictionary tokens) + { + foreach (var item in bundle.CopyItems ?? Array.Empty()) + { + if (item is null) continue; + + var source = ResolvePath(plan.ProjectRoot, ApplyTemplate(item.SourcePath, tokens)); + var destination = ResolvePath(outputDir, ApplyTemplate(item.DestinationPath, tokens)); + EnsurePathWithinRoot(outputDir, destination, $"Bundle '{bundle.Id}' copy destination"); + + CopyBundlePath(source, destination, item.Required, item.ClearDestination, $"Bundle '{bundle.Id}' copy item"); + } + } + + private void CopyBundleModules( + DotNetPublishPlan plan, + DotNetPublishBundlePlan bundle, + string outputDir, + IReadOnlyDictionary tokens) + { + foreach (var module in bundle.ModuleIncludes ?? Array.Empty()) + { + if (module is null) continue; + + var moduleTokens = tokens.ToDictionary( + token => token.Key, + token => token.Value, + StringComparer.OrdinalIgnoreCase); + moduleTokens["moduleName"] = module.ModuleName; + var source = ResolvePath(plan.ProjectRoot, ApplyTemplate(module.SourcePath, moduleTokens)); + var destination = ResolvePath(outputDir, ApplyTemplate(module.DestinationPath, moduleTokens)); + EnsurePathWithinRoot(outputDir, destination, $"Bundle '{bundle.Id}' module include destination"); + + CopyBundlePath(source, destination, module.Required, module.ClearDestination, $"Bundle '{bundle.Id}' module include '{module.ModuleName}'"); + } + } + + private void GenerateBundleScripts( + DotNetPublishPlan plan, + DotNetPublishBundlePlan bundle, + string outputDir, + IReadOnlyDictionary tokens) + { + foreach (var script in bundle.GeneratedScripts ?? Array.Empty()) + { + if (script is null) continue; + + var outputPath = ResolvePath(outputDir, ApplyTemplate(script.OutputPath, tokens)); + EnsurePathWithinRoot(outputDir, outputPath, $"Bundle '{bundle.Id}' generated script output path"); + if (File.Exists(outputPath) && !script.Overwrite) + throw new IOException($"Generated script already exists and Overwrite=false: {outputPath}"); + + var templateName = script.TemplatePath ?? script.OutputPath; + var template = script.Template; + if (!string.IsNullOrWhiteSpace(script.TemplatePath)) + { + var templatePath = ResolvePath(plan.ProjectRoot, ApplyTemplate(script.TemplatePath!, tokens)); + EnsurePathWithinRoot(plan.ProjectRoot, templatePath, $"Bundle '{bundle.Id}' generated script template path"); + if (!File.Exists(templatePath)) + throw new FileNotFoundException($"Generated script template not found for bundle '{bundle.Id}': {templatePath}", templatePath); + templateName = templatePath; + template = File.ReadAllText(templatePath, Encoding.UTF8); + } + + var renderTokens = tokens.ToDictionary( + token => token.Key, + token => token.Value, + StringComparer.OrdinalIgnoreCase); + foreach (var token in script.Tokens ?? new Dictionary(StringComparer.OrdinalIgnoreCase)) + renderTokens[token.Key] = ApplyTemplate(token.Value ?? string.Empty, tokens); + + var rendered = ScriptTemplateRenderer.Render(templateName ?? "bundle generated script", template ?? string.Empty, renderTokens); + var outputDirectory = Path.GetDirectoryName(outputPath) + ?? throw new InvalidOperationException($"Cannot determine parent directory for generated script output: {outputPath}"); + Directory.CreateDirectory(outputDirectory); + File.WriteAllText(outputPath, rendered, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + + _logger.Info($"Generated bundle script -> {FrameworkCompatibility.GetRelativePath(outputDir, outputPath)} ({bundle.Id})"); + if (script.Sign is not null && script.Sign.Enabled) + _ = TrySignFiles(new[] { outputPath }, outputDir, script.Sign, scope: $"bundle '{bundle.Id}' generated scripts"); + } + } + + private void CopyBundlePath( + string source, + string destination, + bool required, + bool clearDestination, + string description) + { + source = Path.GetFullPath(source); + destination = Path.GetFullPath(destination); + + if (Directory.Exists(source)) + { + if (clearDestination && Directory.Exists(destination)) + Directory.Delete(destination, recursive: true); + else if (clearDestination && File.Exists(destination)) + File.Delete(destination); + else if (!clearDestination && (Directory.Exists(destination) || File.Exists(destination))) + throw new IOException($"{description}: destination already exists and ClearDestination=false: {destination}"); + + DirectoryCopy(source, destination); + return; + } + + if (File.Exists(source)) + { + var destinationWasDirectory = Directory.Exists(destination); + var destinationFile = destinationWasDirectory + ? Path.Combine(destination, Path.GetFileName(source)) + : destination; + + Directory.CreateDirectory(Path.GetDirectoryName(destinationFile)!); + if (clearDestination && destinationWasDirectory) + { + Directory.Delete(destination, recursive: true); + Directory.CreateDirectory(destination); + } + if (clearDestination && File.Exists(destinationFile)) + File.Delete(destinationFile); + if (!clearDestination && File.Exists(destinationFile)) + throw new IOException($"{description}: destination already exists and ClearDestination=false: {destinationFile}"); + File.Copy(source, destinationFile, overwrite: clearDestination); + return; + } + + var message = $"{description} source not found: {source}"; + if (required) + throw new FileNotFoundException(message, source); + + _logger.Warn(message); + } + private string? CreateBundleZip( DotNetPublishPlan plan, DotNetPublishBundlePlan bundle, @@ -285,4 +435,96 @@ private void RunPowerShellScript( if (!string.IsNullOrWhiteSpace(result.StdErr)) _logger.Verbose(result.StdErr.TrimEnd()); } } + + private void SignBundlePostProcessFiles( + DotNetPublishPlan plan, + DotNetPublishBundlePlan bundle, + string outputDir) + { + var sign = bundle.PostProcess?.Sign; + if (sign is null || !sign.Enabled) + return; + + var patterns = NormalizeBundleSignPatterns(bundle.PostProcess?.SignPatterns, sign); + var targets = FindBundleSignTargets(outputDir, patterns); + var signed = TrySignFiles( + targets, + outputDir, + sign, + scope: $"bundle '{bundle.Id}' files"); + + _logger.Info($"Bundle sign completed for '{bundle.Id}' -> {signed.Length}/{targets.Length} signed."); + } + + internal static string[] NormalizeBundleSignPatterns( + IReadOnlyList? patterns, + DotNetPublishSignOptions sign) + { + var normalized = (patterns ?? Array.Empty()) + .Where(pattern => !string.IsNullOrWhiteSpace(pattern)) + .Select(pattern => pattern.Trim().Replace('\\', '/')) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (normalized.Length > 0) + return normalized; + + var defaults = new List { "**/*.exe" }; + if (sign.IncludeDlls) + defaults.Add("**/*.dll"); + return defaults.ToArray(); + } + + internal static string[] FindBundleSignTargets(string bundleRoot, IReadOnlyList? patterns) + { + var root = Path.GetFullPath(bundleRoot); + if (!Directory.Exists(root)) + throw new DirectoryNotFoundException($"Bundle root was not found: {root}"); + + var matches = new List(); + foreach (var pattern in patterns ?? Array.Empty()) + { + var normalizedPattern = (pattern ?? string.Empty) + .Trim() + .Replace('\\', '/'); + if (normalizedPattern.Length == 0) + continue; + + var exactPath = ResolvePath(root, normalizedPattern); + if (File.Exists(exactPath)) + { + EnsurePathWithinRoot(root, exactPath, "Bundle signing target"); + matches.Add(exactPath); + continue; + } + + foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) + { + var relative = GetRelativePath(root, file).Replace('\\', '/'); + if (BundleSignPatternMatches(relative, normalizedPattern)) + matches.Add(Path.GetFullPath(file)); + } + } + + return matches + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static bool BundleSignPatternMatches(string relativePath, string pattern) + { + // Match full relative paths, bare basename globs, and rootless **/ globs used by bundle configs. + if (WildcardMatch(relativePath, pattern)) + return true; + + var fileName = Path.GetFileName(relativePath); + if (WildcardMatch(fileName, pattern)) + return true; + + if (pattern.StartsWith("**/", StringComparison.Ordinal)) + return WildcardMatch(relativePath, pattern.Substring("**/".Length)); + + return false; + } } diff --git a/PowerForge/Services/DotNetPublishPipelineRunner.Hooks.cs b/PowerForge/Services/DotNetPublishPipelineRunner.Hooks.cs new file mode 100644 index 00000000..99b4564d --- /dev/null +++ b/PowerForge/Services/DotNetPublishPipelineRunner.Hooks.cs @@ -0,0 +1,188 @@ +using System.Diagnostics; + +namespace PowerForge; + +public sealed partial class DotNetPublishPipelineRunner +{ + internal void RunCommandHook(DotNetPublishPlan plan, DotNetPublishStep step) + { + if (plan is null) throw new ArgumentNullException(nameof(plan)); + if (step is null) throw new ArgumentNullException(nameof(step)); + if (string.IsNullOrWhiteSpace(step.HookCommand)) + throw new InvalidOperationException($"Hook step '{step.Key}' is missing HookCommand."); + + var tokens = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["hook"] = step.HookId ?? string.Empty, + ["phase"] = step.HookPhase?.ToString() ?? string.Empty, + ["target"] = step.TargetName ?? string.Empty, + ["rid"] = step.Runtime ?? string.Empty, + ["framework"] = step.Framework ?? string.Empty, + ["style"] = step.Style?.ToString() ?? string.Empty, + ["bundle"] = step.BundleId ?? string.Empty, + ["configuration"] = plan.Configuration, + ["projectRoot"] = plan.ProjectRoot + }; + + var command = ApplyTemplate(step.HookCommand!, tokens); + var commandPath = ResolveHookCommandPath(plan.ProjectRoot, command); + var args = (step.HookArguments ?? Array.Empty()) + .Select(argument => ApplyTemplate(argument ?? string.Empty, tokens)) + .ToArray(); + var workingDirectory = string.IsNullOrWhiteSpace(step.HookWorkingDirectory) + ? plan.ProjectRoot + : ResolvePath(plan.ProjectRoot, ApplyTemplate(step.HookWorkingDirectory!, tokens)); + var environment = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var entry in step.HookEnvironment ?? new Dictionary(StringComparer.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(entry.Key)) continue; + environment[entry.Key.Trim()] = ApplyTemplate(entry.Value ?? string.Empty, tokens); + } + + _logger.Info($"Hook {step.HookPhase}: {step.HookId}"); + var result = RunHookProcess( + commandPath, + workingDirectory, + args, + environment, + TimeSpan.FromSeconds(Math.Max(1, step.HookTimeoutSeconds))); + + if (result.ExitCode == 0) + { + if (_logger.IsVerbose) + { + if (!string.IsNullOrWhiteSpace(result.StdOut)) _logger.Verbose(result.StdOut.TrimEnd()); + if (!string.IsNullOrWhiteSpace(result.StdErr)) _logger.Verbose(result.StdErr.TrimEnd()); + } + + return; + } + + var stderrTail = TailLines(result.StdErr, maxLines: 40, maxChars: 4000); + var stdoutTail = TailLines(result.StdOut, maxLines: 40, maxChars: 4000); + var message = result.TimedOut + ? $"Hook '{step.HookId}' timed out after {Math.Max(1, step.HookTimeoutSeconds)} seconds." + : ExtractLastNonEmptyLine(!string.IsNullOrWhiteSpace(stderrTail) ? stderrTail : stdoutTail); + if (string.IsNullOrWhiteSpace(message)) + message = $"Hook '{step.HookId}' failed with exit code {result.ExitCode}."; + + if (!step.HookRequired) + { + _logger.Warn(message); + return; + } + + throw new DotNetPublishCommandException( + message, + result.Executable, + string.IsNullOrWhiteSpace(workingDirectory) ? Environment.CurrentDirectory : workingDirectory, + args, + result.ExitCode, + result.StdOut, + result.StdErr); + } + + private static string ResolveHookCommandPath(string projectRoot, string command) + { + var raw = TrimMatchingQuotes((command ?? string.Empty).Trim()); + if (string.IsNullOrWhiteSpace(raw)) + return raw; + if (Path.IsPathRooted(raw)) + return Path.GetFullPath(raw); + if (raw.Contains(Path.DirectorySeparatorChar) || raw.Contains(Path.AltDirectorySeparatorChar)) + return ResolvePath(projectRoot, raw); + return raw; + } + + private static string TrimMatchingQuotes(string value) + { + if (value.Length >= 2 && value[0] == '"' && value[value.Length - 1] == '"') + return value.Substring(1, value.Length - 2); + + return value; + } + + private (int ExitCode, string StdOut, string StdErr, string Executable, bool TimedOut) RunHookProcess( + string fileName, + string workingDirectory, + IReadOnlyList args, + IReadOnlyDictionary environment, + TimeSpan timeout) + { + var psi = new ProcessStartInfo + { + FileName = fileName, + WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? Environment.CurrentDirectory : workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + ProcessStartInfoEncoding.TryApplyUtf8(psi); + + foreach (var entry in environment ?? new Dictionary(StringComparer.OrdinalIgnoreCase)) + psi.Environment[entry.Key] = entry.Value ?? string.Empty; + +#if NET472 + psi.Arguments = BuildWindowsArgumentString(args); +#else + foreach (var arg in args ?? Array.Empty()) + psi.ArgumentList.Add(arg); +#endif + + using var process = Process.Start(psi) + ?? throw new InvalidOperationException($"Failed to start hook command: {fileName}"); + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + var timeoutMs = (int)Math.Min(Math.Max(1, timeout.TotalMilliseconds), int.MaxValue); + + if (!process.WaitForExit(timeoutMs)) + { + try + { +#if NET472 + process.Kill(); +#else + process.Kill(entireProcessTree: true); +#endif + } + catch (Exception ex) + { + _logger.Verbose($"Hook timeout kill failed for '{fileName}': {ex.Message}"); + } + + try + { + // Bound stream draining after kill so timeout handling cannot hang on net472 pipe reads. + Task.WhenAll(stdoutTask, stderrTask).Wait(TimeSpan.FromSeconds(5)); + } + catch + { + // Best effort after timeout; return whatever stream data completed. + } + + var stdout = TryGetCompletedOutput(stdoutTask); + var stderr = TryGetCompletedOutput(stderrTask); + return (-1, stdout, stderr, fileName, TimedOut: true); + } + +#if NET472 + process.WaitForExit(30_000); +#else + process.WaitForExit(); +#endif + return ( + process.ExitCode, + stdoutTask.GetAwaiter().GetResult(), + stderrTask.GetAwaiter().GetResult(), + fileName, + TimedOut: false); + } + + private static string TryGetCompletedOutput(Task task) + { + return task.IsCompleted && !task.IsFaulted && !task.IsCanceled + ? task.GetAwaiter().GetResult() + : string.Empty; + } +} diff --git a/PowerForge/Services/DotNetPublishPipelineRunner.MsiPrepare.cs b/PowerForge/Services/DotNetPublishPipelineRunner.MsiPrepare.cs index 46e1fa25..20a8e285 100644 --- a/PowerForge/Services/DotNetPublishPipelineRunner.MsiPrepare.cs +++ b/PowerForge/Services/DotNetPublishPipelineRunner.MsiPrepare.cs @@ -34,6 +34,7 @@ internal DotNetPublishMsiPrepareResult PrepareMsiPackage( if (string.IsNullOrWhiteSpace(step.ManifestPath)) throw new InvalidOperationException($"Step '{step.Key}' is missing manifest path."); + var installerPlan = ResolveInstallerPlan(plan, installerId); var sourceBundleId = ResolveInstallerSourceBundleId(plan, installerId, step.BundleId); var sourceArtefact = ResolveInstallerSourceArtefact( artefacts, @@ -103,7 +104,11 @@ internal DotNetPublishMsiPrepareResult PrepareMsiPackage( Directory.CreateDirectory(Path.GetDirectoryName(resolvedHarvestPath)!); File.WriteAllText( resolvedHarvestPath, - BuildWixHarvestFragment(stagingPath, resolvedHarvestDirectoryRefId, resolvedHarvestComponentGroupId), + BuildWixHarvestFragment( + stagingPath, + resolvedHarvestDirectoryRefId, + resolvedHarvestComponentGroupId, + installerPlan?.HarvestExcludePatterns), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); _logger.Info($"MSI prepare harvest -> {resolvedHarvestPath}"); @@ -140,6 +145,12 @@ internal DotNetPublishMsiPrepareResult PrepareMsiPackage( return result; } + private static DotNetPublishInstallerPlan? ResolveInstallerPlan(DotNetPublishPlan plan, string installerId) + { + return (plan.Installers ?? Array.Empty()) + .FirstOrDefault(entry => string.Equals(entry.Id, installerId, StringComparison.OrdinalIgnoreCase)); + } + private static string? ResolveInstallerSourceBundleId( DotNetPublishPlan plan, string installerId, @@ -178,9 +189,12 @@ internal DotNetPublishMsiPrepareResult PrepareMsiPackage( internal static string BuildWixHarvestFragment( string stagingPath, string directoryRefId, - string componentGroupId) + string componentGroupId, + IEnumerable? excludePatterns = null) { + var normalizedExcludes = NormalizeHarvestExcludePatterns(excludePatterns); var files = Directory.EnumerateFiles(stagingPath, "*", SearchOption.AllDirectories) + .Where(file => !ShouldExcludeHarvestFile(stagingPath, file, normalizedExcludes)) .OrderBy(p => p, StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -268,6 +282,38 @@ static void AppendDirectories( return sb.ToString(); } + private static string[] NormalizeHarvestExcludePatterns(IEnumerable? patterns) + { + return (patterns ?? Array.Empty()) + .Where(pattern => !string.IsNullOrWhiteSpace(pattern)) + .Select(pattern => pattern.Trim().Replace('\\', '/')) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static bool ShouldExcludeHarvestFile(string stagingPath, string file, IReadOnlyList excludePatterns) + { + if (excludePatterns is null || excludePatterns.Count == 0) + return false; + + var relative = GetRelativePathCompat(stagingPath, file).Replace('\\', '/'); + var fileName = Path.GetFileName(relative); + foreach (var pattern in excludePatterns) + { + var rootlessGlobstar = pattern.StartsWith("**/", StringComparison.Ordinal) + ? pattern.Substring(3) + : null; + if (WildcardMatch(relative, pattern) + || WildcardMatch(fileName, pattern) + || (rootlessGlobstar is not null && WildcardMatch(relative, rootlessGlobstar))) + { + return true; + } + } + + return false; + } + private static string BuildHarvestId(string prefix, string value) { using var sha256 = SHA256.Create(); diff --git a/PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs b/PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs index 57fa87d1..0fab30ca 100644 --- a/PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs +++ b/PowerForge/Services/DotNetPublishPipelineRunner.Plan.cs @@ -8,6 +8,8 @@ namespace PowerForge; public sealed partial class DotNetPublishPipelineRunner { + private const int DefaultCommandHookTimeoutSeconds = DotNetPublishCommandHook.DefaultTimeoutSeconds; + private const string DefaultMsiPrepareStagingPathTemplate = "Artifacts/DotNetPublish/Msi/{installer}/{target}/{rid}/{framework}/{style}/payload"; @@ -175,7 +177,8 @@ public DotNetPublishPlan Plan(DotNetPublishSpec spec, string? configPath) }); } - var bundles = BuildBundlePlans(spec.Bundles, targets, projectRoot); + var bundles = BuildBundlePlans(spec.Bundles, targets, projectRoot, spec.SigningProfiles); + targets = OrderTargetsForBundleIncludes(targets, bundles); var installers = BuildInstallerPlans(spec.Installers, bundles, targets, projectCatalog, projectRoot, spec.SigningProfiles); var storePackages = BuildStorePackagePlans(spec.StorePackages, targets, projectCatalog, projectRoot); @@ -196,6 +199,7 @@ public DotNetPublishPlan Plan(DotNetPublishSpec spec, string? configPath) }; var benchmarkGates = BuildBenchmarkGatePlans(spec.BenchmarkGates, projectRoot); + var hooks = NormalizeCommandHooks(spec.Hooks); if (!spec.DotNet.AllowManifestOutsideProjectRoot) { @@ -295,11 +299,13 @@ public DotNetPublishPlan Plan(DotNetPublishSpec spec, string? configPath) .OrderBy(r => r, StringComparer.OrdinalIgnoreCase) .ToArray(); - if (spec.DotNet.Clean) - steps.Add(new DotNetPublishStep { Key = "clean", Kind = DotNetPublishStepKind.Clean, Title = "Clean" }); - - if (spec.DotNet.Restore) - { + if (spec.DotNet.Clean) + steps.Add(new DotNetPublishStep { Key = "clean", Kind = DotNetPublishStepKind.Clean, Title = "Clean" }); + + AddCommandHookSteps(steps, hooks, DotNetPublishCommandHookPhase.BeforeRestore, cfg, targetName: null, framework: null, runtime: null, style: null, bundleId: null); + + if (spec.DotNet.Restore) + { if (spec.DotNet.NoRestoreInPublish && distinctRuntimes.Length > 0) { foreach (var rid in distinctRuntimes) @@ -316,11 +322,13 @@ public DotNetPublishPlan Plan(DotNetPublishSpec spec, string? configPath) else { steps.Add(new DotNetPublishStep { Key = "restore", Kind = DotNetPublishStepKind.Restore, Title = "Restore" }); - } - } - - if (spec.DotNet.Build) - { + } + } + + AddCommandHookSteps(steps, hooks, DotNetPublishCommandHookPhase.BeforeBuild, cfg, targetName: null, framework: null, runtime: null, style: null, bundleId: null); + + if (spec.DotNet.Build) + { if (spec.DotNet.NoBuildInPublish && distinctRuntimes.Length > 0) { foreach (var rid in distinctRuntimes) @@ -344,6 +352,8 @@ public DotNetPublishPlan Plan(DotNetPublishSpec spec, string? configPath) { foreach (var combo in t.Combinations ?? Array.Empty()) { + AddCommandHookSteps(steps, hooks, DotNetPublishCommandHookPhase.BeforeTargetPublish, cfg, t.Name, combo.Framework, combo.Runtime, combo.Style, bundleId: null); + var key = $"publish:{t.Name}:{combo.Framework}:{combo.Runtime}:{combo.Style}"; steps.Add(new DotNetPublishStep { @@ -356,6 +366,8 @@ public DotNetPublishPlan Plan(DotNetPublishSpec spec, string? configPath) Style = combo.Style }); + AddCommandHookSteps(steps, hooks, DotNetPublishCommandHookPhase.AfterTargetPublish, cfg, t.Name, combo.Framework, combo.Runtime, combo.Style, bundleId: null); + if (t.Publish.Service?.Lifecycle?.Enabled == true && t.Publish.Service.Lifecycle.Mode == DotNetPublishServiceLifecycleMode.Step) { @@ -398,7 +410,9 @@ public DotNetPublishPlan Plan(DotNetPublishSpec spec, string? configPath) "Use unique bundle IDs or path templates."); } + AddCommandHookSteps(steps, hooks, DotNetPublishCommandHookPhase.BeforeBundle, cfg, t.Name, combo.Framework, combo.Runtime, combo.Style, bundle.Id); steps.Add(bundleStep); + AddCommandHookSteps(steps, hooks, DotNetPublishCommandHookPhase.AfterBundle, cfg, t.Name, combo.Framework, combo.Runtime, combo.Style, bundle.Id); } foreach (var installer in installers.Where(i => string.Equals(i.PrepareFromTarget, t.Name, StringComparison.OrdinalIgnoreCase))) @@ -622,6 +636,7 @@ private static DotNetPublishSpec ResolveProfile(DotNetPublishSpec spec) || selectedTargetNames.Contains(i.PrepareFromTarget.Trim())) .ToArray(), BenchmarkGates = CloneBenchmarkGates(spec.BenchmarkGates), + Hooks = CloneCommandHooks(spec.Hooks), Matrix = CloneMatrix(spec.Matrix), DotNet = dotNet, Targets = selectedTargets, @@ -699,6 +714,7 @@ private static DotNetPublishInstaller[] CloneInstallers(DotNetPublishInstaller[] HarvestPath = i.HarvestPath, HarvestDirectoryRefId = i.HarvestDirectoryRefId, HarvestComponentGroupId = i.HarvestComponentGroupId, + HarvestExcludePatterns = NormalizeStrings(i.HarvestExcludePatterns), Versioning = CloneMsiVersionOptions(i.Versioning), MsBuildProperties = CloneDictionary(i.MsBuildProperties), SignProfile = i.SignProfile, @@ -746,12 +762,17 @@ private static DotNetPublishBundle[] CloneBundles(DotNetPublishBundle[] bundles) Frameworks = NormalizeStrings(b.Frameworks), Styles = NormalizeStyles(b.Styles), OutputPath = b.OutputPath, + PrimarySubdirectory = b.PrimarySubdirectory, ClearOutput = b.ClearOutput, Zip = b.Zip, ZipPath = b.ZipPath, ZipNameTemplate = b.ZipNameTemplate, Includes = CloneBundleIncludes(b.Includes), - Scripts = CloneBundleScripts(b.Scripts) + CopyItems = CloneBundleCopyItems(b.CopyItems), + ModuleIncludes = CloneBundleModuleIncludes(b.ModuleIncludes), + GeneratedScripts = CloneBundleGeneratedScripts(b.GeneratedScripts), + Scripts = CloneBundleScripts(b.Scripts), + PostProcess = CloneBundlePostProcessOptions(b.PostProcess) }) .ToArray(); } @@ -772,6 +793,53 @@ private static DotNetPublishBundleInclude[] CloneBundleIncludes(DotNetPublishBun .ToArray(); } + private static DotNetPublishBundleCopyItem[] CloneBundleCopyItems(DotNetPublishBundleCopyItem[] items) + { + return (items ?? Array.Empty()) + .Where(i => i is not null) + .Select(i => new DotNetPublishBundleCopyItem + { + SourcePath = i.SourcePath, + DestinationPath = i.DestinationPath, + Required = i.Required, + ClearDestination = i.ClearDestination + }) + .ToArray(); + } + + private static DotNetPublishBundleModuleInclude[] CloneBundleModuleIncludes(DotNetPublishBundleModuleInclude[] modules) + { + return (modules ?? Array.Empty()) + .Where(m => m is not null) + .Select(m => new DotNetPublishBundleModuleInclude + { + ModuleName = m.ModuleName, + SourcePath = m.SourcePath, + DestinationPath = m.DestinationPath, + Required = m.Required, + ClearDestination = m.ClearDestination + }) + .ToArray(); + } + + private static DotNetPublishBundleGeneratedScript[] CloneBundleGeneratedScripts(DotNetPublishBundleGeneratedScript[] scripts) + { + return (scripts ?? Array.Empty()) + .Where(s => s is not null) + .Select(s => new DotNetPublishBundleGeneratedScript + { + TemplatePath = s.TemplatePath, + Template = s.Template, + OutputPath = s.OutputPath, + Tokens = CloneDictionary(s.Tokens), + Overwrite = s.Overwrite, + SignProfile = s.SignProfile, + Sign = DotNetPublishSigningProfileResolver.CloneSignOptions(s.Sign), + SignOverrides = DotNetPublishSigningProfileResolver.CloneSignPatch(s.SignOverrides) + }) + .ToArray(); + } + private static DotNetPublishBundleScript[] CloneBundleScripts(DotNetPublishBundleScript[] scripts) { return (scripts ?? Array.Empty()) @@ -788,6 +856,28 @@ private static DotNetPublishBundleScript[] CloneBundleScripts(DotNetPublishBundl .ToArray(); } + private static DotNetPublishCommandHook[] CloneCommandHooks(DotNetPublishCommandHook[]? hooks) + { + return (hooks ?? Array.Empty()) + .Where(h => h is not null) + .Select(h => new DotNetPublishCommandHook + { + Id = h.Id, + Phase = h.Phase, + Command = h.Command, + Arguments = NormalizeArguments(h.Arguments), + WorkingDirectory = h.WorkingDirectory, + Environment = CloneDictionary(h.Environment), + TimeoutSeconds = h.TimeoutSeconds, + Required = h.Required, + Targets = NormalizeStrings(h.Targets), + Runtimes = NormalizeStrings(h.Runtimes), + Frameworks = NormalizeStrings(h.Frameworks), + Styles = NormalizeStyles(h.Styles) + }) + .ToArray(); + } + private static DotNetPublishBenchmarkGate[] CloneBenchmarkGates(DotNetPublishBenchmarkGate[]? gates) { return (gates ?? Array.Empty()) @@ -1058,6 +1148,13 @@ private static string[] NormalizeStrings(IEnumerable? values) .ToArray(); } + private static string[] NormalizeArguments(IEnumerable? values) + { + return (values ?? Array.Empty()) + .Select(v => v ?? string.Empty) + .ToArray(); + } + private static DotNetPublishStyle[] NormalizeStyles(IEnumerable? values) { return (values ?? Array.Empty()) @@ -1065,6 +1162,121 @@ private static DotNetPublishStyle[] NormalizeStyles(IEnumerable? hooks) + { + var result = new List(); + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var hook in hooks ?? Array.Empty()) + { + if (hook is null) continue; + var id = (hook.Id ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(id)) + throw new ArgumentException("Hooks[].Id is required."); + if (!ids.Add(id)) + throw new ArgumentException($"Duplicate hook ID detected: {id}"); + if (string.IsNullOrWhiteSpace(hook.Command)) + throw new ArgumentException($"Hooks['{id}'].Command is required."); + + result.Add(new DotNetPublishCommandHook + { + Id = id, + Phase = hook.Phase, + Command = hook.Command.Trim(), + Arguments = NormalizeArguments(hook.Arguments), + WorkingDirectory = string.IsNullOrWhiteSpace(hook.WorkingDirectory) ? null : hook.WorkingDirectory!.Trim(), + Environment = CloneDictionary(hook.Environment), + TimeoutSeconds = hook.TimeoutSeconds <= 0 + ? DefaultCommandHookTimeoutSeconds + : Math.Max(1, hook.TimeoutSeconds), + Required = hook.Required, + Targets = NormalizeStrings(hook.Targets), + Runtimes = NormalizeStrings(hook.Runtimes), + Frameworks = NormalizeStrings(hook.Frameworks), + Styles = NormalizeStyles(hook.Styles) + }); + } + + return result.ToArray(); + } + + private static void AddCommandHookSteps( + List steps, + IReadOnlyList hooks, + DotNetPublishCommandHookPhase phase, + string configuration, + string? targetName, + string? framework, + string? runtime, + DotNetPublishStyle? style, + string? bundleId) + { + foreach (var hook in hooks.Where(hook => hook.Phase == phase)) + { + if (!CommandHookMatches(hook, targetName, framework, runtime, style)) + continue; + + var suffixParts = new[] { targetName, framework, runtime, style?.ToString(), bundleId } + .Where(part => !string.IsNullOrWhiteSpace(part)) + .Select(part => part!.Trim()) + .ToArray(); + var suffix = suffixParts.Length == 0 ? string.Empty : ":" + string.Join(":", suffixParts); + + steps.Add(new DotNetPublishStep + { + Key = $"hook:{phase}:{hook.Id}{suffix}", + Kind = DotNetPublishStepKind.CommandHook, + Title = $"Hook {phase}: {hook.Id}", + HookId = hook.Id, + HookPhase = phase, + HookCommand = hook.Command, + HookArguments = hook.Arguments, + HookWorkingDirectory = hook.WorkingDirectory, + HookEnvironment = hook.Environment ?? new Dictionary(StringComparer.OrdinalIgnoreCase), + HookTimeoutSeconds = hook.TimeoutSeconds, + HookRequired = hook.Required, + TargetName = targetName, + Framework = framework, + Runtime = runtime, + Style = style, + BundleId = bundleId + }); + } + } + + private static bool CommandHookMatches( + DotNetPublishCommandHook hook, + string? targetName, + string? framework, + string? runtime, + DotNetPublishStyle? style) + { + if (hook.Targets.Length > 0) + { + if (string.IsNullOrWhiteSpace(targetName) || !hook.Targets.Any(pattern => WildcardMatch(targetName!, pattern))) + return false; + } + + if (hook.Frameworks.Length > 0) + { + if (string.IsNullOrWhiteSpace(framework) || !hook.Frameworks.Any(pattern => WildcardMatch(framework!, pattern))) + return false; + } + + if (hook.Runtimes.Length > 0) + { + if (string.IsNullOrWhiteSpace(runtime) || !hook.Runtimes.Any(pattern => WildcardMatch(runtime!, pattern))) + return false; + } + + if (hook.Styles.Length > 0) + { + if (!style.HasValue || !hook.Styles.Contains(style.Value)) + return false; + } + + return true; + } + private static Dictionary BuildProjectCatalog(IEnumerable? projects, string projectRoot) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -1106,7 +1318,8 @@ private static string ResolveTargetProjectPath(string projectRoot, DotNetPublish private static DotNetPublishBundlePlan[] BuildBundlePlans( IEnumerable? bundles, IEnumerable? targets, - string projectRoot) + string projectRoot, + IReadOnlyDictionary? signingProfiles) { var targetPlans = (targets ?? Array.Empty()) .Where(t => t is not null && !string.IsNullOrWhiteSpace(t.Name)) @@ -1165,6 +1378,73 @@ private static DotNetPublishBundlePlan[] BuildBundlePlans( }); } + var copyItemPlans = new List(); + foreach (var item in bundle.CopyItems ?? Array.Empty()) + { + if (item is null) continue; + if (string.IsNullOrWhiteSpace(item.SourcePath)) + throw new ArgumentException($"Bundle '{id}' CopyItems[] requires SourcePath."); + if (string.IsNullOrWhiteSpace(item.DestinationPath)) + throw new ArgumentException($"Bundle '{id}' CopyItems[] requires DestinationPath."); + + copyItemPlans.Add(new DotNetPublishBundleCopyItemPlan + { + SourcePath = item.SourcePath.Trim(), + DestinationPath = item.DestinationPath.Trim(), + Required = item.Required, + ClearDestination = item.ClearDestination + }); + } + + var moduleIncludePlans = new List(); + foreach (var module in bundle.ModuleIncludes ?? Array.Empty()) + { + if (module is null) continue; + var moduleName = (module.ModuleName ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(moduleName)) + throw new ArgumentException($"Bundle '{id}' ModuleIncludes[] requires ModuleName."); + if (string.IsNullOrWhiteSpace(module.SourcePath)) + throw new ArgumentException($"Bundle '{id}' ModuleIncludes['{moduleName}'] requires SourcePath."); + + moduleIncludePlans.Add(new DotNetPublishBundleModuleIncludePlan + { + ModuleName = moduleName, + SourcePath = module.SourcePath.Trim(), + DestinationPath = string.IsNullOrWhiteSpace(module.DestinationPath) + ? $"Modules/{{moduleName}}" + : module.DestinationPath!.Trim(), + Required = module.Required, + ClearDestination = module.ClearDestination + }); + } + + var generatedScriptPlans = new List(); + foreach (var generated in bundle.GeneratedScripts ?? Array.Empty()) + { + if (generated is null) continue; + if (string.IsNullOrWhiteSpace(generated.OutputPath)) + throw new ArgumentException($"Bundle '{id}' GeneratedScripts[] requires OutputPath."); + if (string.IsNullOrWhiteSpace(generated.TemplatePath) && string.IsNullOrWhiteSpace(generated.Template)) + throw new ArgumentException($"Bundle '{id}' GeneratedScripts['{generated.OutputPath}'] requires TemplatePath or Template."); + + generatedScriptPlans.Add(new DotNetPublishBundleGeneratedScriptPlan + { + TemplatePath = string.IsNullOrWhiteSpace(generated.TemplatePath) + ? null + : generated.TemplatePath!.Trim(), + Template = generated.Template, + OutputPath = generated.OutputPath.Trim(), + Tokens = CloneDictionary(generated.Tokens) ?? new Dictionary(StringComparer.OrdinalIgnoreCase), + Overwrite = generated.Overwrite, + Sign = DotNetPublishSigningProfileResolver.ResolveConfiguredSignOptions( + signingProfiles, + generated.SignProfile, + generated.Sign, + generated.SignOverrides, + $"Bundle '{id}' generated script '{generated.OutputPath}'") + }); + } + var scriptPlans = new List(); foreach (var script in bundle.Scripts ?? Array.Empty()) { @@ -1196,13 +1476,19 @@ private static DotNetPublishBundlePlan[] BuildBundlePlans( Frameworks = frameworks, Styles = styles, OutputPath = bundle.OutputPath, + PrimarySubdirectory = string.IsNullOrWhiteSpace(bundle.PrimarySubdirectory) + ? null + : bundle.PrimarySubdirectory!.Trim(), ClearOutput = bundle.ClearOutput, Zip = bundle.Zip, ZipPath = bundle.ZipPath, ZipNameTemplate = bundle.ZipNameTemplate, Includes = includePlans.ToArray(), + CopyItems = copyItemPlans.ToArray(), + ModuleIncludes = moduleIncludePlans.ToArray(), + GeneratedScripts = generatedScriptPlans.ToArray(), Scripts = scriptPlans.ToArray(), - PostProcess = NormalizeBundlePostProcess(id, bundle.PostProcess) + PostProcess = NormalizeBundlePostProcess(id, bundle.PostProcess, signingProfiles) }); } @@ -1295,6 +1581,7 @@ private static DotNetPublishInstallerPlan[] BuildInstallerPlans( HarvestPath = installer.HarvestPath, HarvestDirectoryRefId = installer.HarvestDirectoryRefId, HarvestComponentGroupId = installer.HarvestComponentGroupId, + HarvestExcludePatterns = NormalizeStrings(installer.HarvestExcludePatterns), Versioning = NormalizeInstallerVersioning(id, installer.Versioning), MsBuildProperties = CloneDictionary(installer.MsBuildProperties), Sign = DotNetPublishSigningProfileResolver.ResolveConfiguredSignOptions( @@ -1310,6 +1597,71 @@ private static DotNetPublishInstallerPlan[] BuildInstallerPlans( return plans.ToArray(); } + private static List OrderTargetsForBundleIncludes( + List targets, + IReadOnlyList bundles) + { + if (targets is null || targets.Count < 2 || bundles is null || bundles.Count == 0) + return targets ?? new List(); + + var targetMap = targets + .Where(t => t is not null && !string.IsNullOrWhiteSpace(t.Name)) + .ToDictionary(t => t.Name, StringComparer.OrdinalIgnoreCase); + var dependencies = targetMap.Keys.ToDictionary( + name => name, + _ => new HashSet(StringComparer.OrdinalIgnoreCase), + StringComparer.OrdinalIgnoreCase); + + foreach (var bundle in bundles) + { + if (bundle is null || string.IsNullOrWhiteSpace(bundle.PrepareFromTarget)) + continue; + if (!dependencies.TryGetValue(bundle.PrepareFromTarget, out var sourceDependencies)) + continue; + + foreach (var include in bundle.Includes ?? Array.Empty()) + { + if (include is null || string.IsNullOrWhiteSpace(include.Target)) + continue; + if (targetMap.ContainsKey(include.Target)) + sourceDependencies.Add(include.Target); + } + } + + if (dependencies.Values.All(set => set.Count == 0)) + return targets; + + var ordered = new List(targets.Count); + var visiting = new HashSet(StringComparer.OrdinalIgnoreCase); + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + + void Visit(string targetName) + { + if (visited.Contains(targetName)) + return; + if (!visiting.Add(targetName)) + throw new ArgumentException($"Bundle include target dependency cycle detected at '{targetName}'."); + + if (dependencies.TryGetValue(targetName, out var deps)) + { + foreach (var dependency in deps) + { + if (targetMap.ContainsKey(dependency)) + Visit(dependency); + } + } + + visiting.Remove(targetName); + visited.Add(targetName); + ordered.Add(targetMap[targetName]); + } + + foreach (var target in targets) + Visit(target.Name); + + return ordered; + } + private static DotNetPublishStorePackagePlan[] BuildStorePackagePlans( IEnumerable? storePackages, IEnumerable? targets, @@ -1866,7 +2218,8 @@ private static string BuildInstallerFilterSummary( private static DotNetPublishBundlePostProcessOptions? NormalizeBundlePostProcess( string bundleId, - DotNetPublishBundlePostProcessOptions? options) + DotNetPublishBundlePostProcessOptions? options, + IReadOnlyDictionary? signingProfiles) { var clone = CloneBundlePostProcessOptions(options); if (clone is null) @@ -1888,6 +2241,15 @@ private static string BuildInstallerFilterSummary( .ToArray(); clone.DeletePatterns = NormalizeStrings(clone.DeletePatterns); + clone.SignPatterns = NormalizeStrings(clone.SignPatterns); + clone.Sign = DotNetPublishSigningProfileResolver.ResolveConfiguredSignOptions( + signingProfiles, + clone.SignProfile, + clone.Sign, + clone.SignOverrides, + $"Bundle '{bundleId}' post-process signing"); + clone.SignProfile = null; + clone.SignOverrides = null; if (clone.Metadata is not null) { @@ -1995,6 +2357,10 @@ private static string BuildInstallerFilterSummary( }) .ToArray(), DeletePatterns = NormalizeStrings(options.DeletePatterns), + SignPatterns = NormalizeStrings(options.SignPatterns), + SignProfile = options.SignProfile, + Sign = DotNetPublishSigningProfileResolver.CloneSignOptions(options.Sign), + SignOverrides = DotNetPublishSigningProfileResolver.CloneSignPatch(options.SignOverrides), Metadata = options.Metadata is null ? null : new DotNetPublishBundleMetadataOptions diff --git a/PowerForge/Services/DotNetPublishPipelineRunner.Run.cs b/PowerForge/Services/DotNetPublishPipelineRunner.Run.cs index a9a18407..487db203 100644 --- a/PowerForge/Services/DotNetPublishPipelineRunner.Run.cs +++ b/PowerForge/Services/DotNetPublishPipelineRunner.Run.cs @@ -42,9 +42,12 @@ public DotNetPublishResult Run(DotNetPublishPlan plan, IDotNetPublishProgressRep { switch (step.Kind) { + case DotNetPublishStepKind.CommandHook: + RunCommandHook(plan, step); + break; case DotNetPublishStepKind.Restore: - Restore(plan, step.Runtime); - break; + Restore(plan, step.Runtime); + break; case DotNetPublishStepKind.Clean: Clean(plan); break; diff --git a/PowerForge/Services/FormattingPipeline.cs b/PowerForge/Services/FormattingPipeline.cs index 95e536b6..66fced6a 100644 --- a/PowerForge/Services/FormattingPipeline.cs +++ b/PowerForge/Services/FormattingPipeline.cs @@ -31,6 +31,14 @@ public IReadOnlyList Run(IEnumerable files, FormatOptio var list = files.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); if (list.Length == 0) return Array.Empty(); + // Step 0: normalize first so PSScriptAnalyzer can parse files that have mixed line endings. + var opts = new NormalizationOptions(options.LineEnding, options.Utf8Bom); + var preNormalize = new List(list.Length); + foreach (var f in list) + { + preNormalize.Add(_norm.NormalizeFile(f, opts)); + } + // Step 1: Preprocess var pre = _pre.Process(list, options); @@ -39,18 +47,19 @@ public IReadOnlyList Run(IEnumerable files, FormatOptio // Step 3: Normalize var results = new List(list.Length); - var opts = new NormalizationOptions(options.LineEnding, options.Utf8Bom); foreach (var f in list) { var n = _norm.NormalizeFile(f, opts); + var preNormalizeResult = preNormalize.FirstOrDefault(x => string.Equals(x.Path, f, StringComparison.OrdinalIgnoreCase)); var preResult = pre.FirstOrDefault(x => string.Equals(x.Path, f, StringComparison.OrdinalIgnoreCase)); var pssaResult = pssa.FirstOrDefault(x => string.Equals(x.Path, f, StringComparison.OrdinalIgnoreCase)); + bool preNormalizeChanged = preNormalizeResult?.Changed ?? false; bool preChanged = preResult?.Changed ?? false; bool pssaChanged = pssaResult?.Changed ?? false; - bool changed = preChanged || pssaChanged || n.Changed; + bool changed = preNormalizeChanged || preChanged || pssaChanged || n.Changed; - var details = $"pre={(preChanged ? '1' : '0')}; pssa={(pssaChanged ? '1' : '0')}; norm={(n.Changed ? '1' : '0')}"; + var details = $"preNorm={(preNormalizeChanged ? '1' : '0')}; pre={(preChanged ? '1' : '0')}; pssa={(pssaChanged ? '1' : '0')}; norm={(n.Changed ? '1' : '0')}"; var preMsg = preResult?.Message ?? string.Empty; var pssaMsg = pssaResult?.Message ?? string.Empty; @@ -67,4 +76,3 @@ public IReadOnlyList Run(IEnumerable files, FormatOptio return results; } } - diff --git a/PowerForge/Services/LineEndingsNormalizer.cs b/PowerForge/Services/LineEndingsNormalizer.cs index f614fdcf..9fd8d72f 100644 --- a/PowerForge/Services/LineEndingsNormalizer.cs +++ b/PowerForge/Services/LineEndingsNormalizer.cs @@ -63,17 +63,12 @@ private static string DetectDominant(string text) /// private static string NormalizeEndings(string text, string target) { - // Fast path: already consistent if (target == "\r\n") { - // Replace bare LF not preceded by CR - return text.Replace("\r\n", "\n").Replace("\n", "\r\n"); - } - else - { - // Target LF: remove CRs before LFs - return text.Replace("\r\n", "\n"); + return text.Replace("\r\n", "\n").Replace("\r", "\n").Replace("\n", "\r\n"); } + + return text.Replace("\r\n", "\n").Replace("\r", "\n"); } /// diff --git a/PowerForge/Services/ScriptTemplateRenderer.cs b/PowerForge/Services/ScriptTemplateRenderer.cs index 795f62af..dc343bdf 100644 --- a/PowerForge/Services/ScriptTemplateRenderer.cs +++ b/PowerForge/Services/ScriptTemplateRenderer.cs @@ -16,7 +16,12 @@ internal static string Render( { var name = string.IsNullOrWhiteSpace(templateName) ? "template" : templateName.Trim(); var text = template ?? string.Empty; - var map = tokens ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kv in tokens ?? new Dictionary(StringComparer.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(kv.Key)) continue; + map[kv.Key] = kv.Value ?? string.Empty; + } var referencedTokens = TokenRegex.Matches(text) .Cast() @@ -35,24 +40,14 @@ internal static string Render( $"Template '{name}' references missing token(s): {string.Join(", ", missing)}."); } - foreach (var kv in map) + text = TokenRegex.Replace(text, match => { - if (string.IsNullOrWhiteSpace(kv.Key)) continue; - text = text.Replace("{{" + kv.Key + "}}", kv.Value ?? string.Empty); - } + var token = match.Groups[1].Value; + if (!map.TryGetValue(token, out var value)) + throw new InvalidOperationException($"Template '{name}' references missing token(s): {token}."); - var unresolved = TokenRegex.Matches(text) - .Cast() - .Select(m => m.Groups.Count > 1 ? m.Groups[1].Value : string.Empty) - .Where(v => !string.IsNullOrWhiteSpace(v)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(v => v, StringComparer.OrdinalIgnoreCase) - .ToArray(); - if (unresolved.Length > 0) - { - throw new InvalidOperationException( - $"Template '{name}' contains unresolved token(s): {string.Join(", ", unresolved)}."); - } + return value; + }); return text.Replace("\r\n", "\n").Replace("\n", Environment.NewLine); } diff --git a/Schemas/powerforge.dotnetpublish.schema.json b/Schemas/powerforge.dotnetpublish.schema.json index 6fce7a15..1849277a 100644 --- a/Schemas/powerforge.dotnetpublish.schema.json +++ b/Schemas/powerforge.dotnetpublish.schema.json @@ -42,6 +42,10 @@ "type": "array", "items": { "$ref": "#/$defs/DotNetPublishBenchmarkGate" } }, + "Hooks": { + "type": "array", + "items": { "$ref": "#/$defs/DotNetPublishCommandHook" } + }, "Outputs": { "$ref": "#/$defs/DotNetPublishOutputs" } }, "required": ["Targets"], @@ -86,6 +90,10 @@ "type": "string", "enum": ["First", "Last", "Min", "Max", "Average"] }, + "DotNetPublishCommandHookPhase": { + "type": "string", + "enum": ["BeforeRestore", "BeforeBuild", "BeforeTargetPublish", "AfterTargetPublish", "BeforeBundle", "AfterBundle"] + }, "DotNetPublishBaselineMode": { "type": "string", "enum": ["Verify", "Update"] @@ -358,6 +366,11 @@ "HarvestPath": { "type": ["string", "null"] }, "HarvestDirectoryRefId": { "type": ["string", "null"] }, "HarvestComponentGroupId": { "type": ["string", "null"] }, + "HarvestExcludePatterns": { + "type": "array", + "description": "Relative wildcard or basename patterns excluded from WiX harvest. Bare basenames match anywhere under the staged payload.", + "items": { "type": "string", "minLength": 1 } + }, "Versioning": { "anyOf": [{ "$ref": "#/$defs/DotNetPublishMsiVersionOptions" }, { "type": "null" }] }, "MsBuildProperties": { "type": ["object", "null"], @@ -410,6 +423,64 @@ }, "required": ["Target"] }, + "DotNetPublishBundleCopyItem": { + "type": "object", + "additionalProperties": false, + "properties": { + "SourcePath": { "type": "string", "minLength": 1 }, + "DestinationPath": { "type": "string", "minLength": 1 }, + "Required": { "type": "boolean" }, + "ClearDestination": { + "type": "boolean", + "description": "When false, an existing destination is preserved by failing the copy instead of replacing it." + } + }, + "required": ["SourcePath", "DestinationPath"] + }, + "DotNetPublishBundleModuleInclude": { + "type": "object", + "additionalProperties": false, + "properties": { + "ModuleName": { "type": "string", "minLength": 1 }, + "SourcePath": { "type": "string", "minLength": 1 }, + "DestinationPath": { "type": ["string", "null"] }, + "Required": { "type": "boolean" }, + "ClearDestination": { + "type": "boolean", + "description": "When false, an existing module destination is preserved by failing the copy instead of replacing it." + } + }, + "required": ["ModuleName", "SourcePath"] + }, + "DotNetPublishBundleGeneratedScript": { + "type": "object", + "additionalProperties": false, + "properties": { + "TemplatePath": { + "type": ["string", "null"], + "description": "Template file path resolved under ProjectRoot. Path tokens use single braces, for example {output}." + }, + "Template": { "type": ["string", "null"] }, + "OutputPath": { "type": "string", "minLength": 1 }, + "Tokens": { + "type": ["object", "null"], + "description": "Template token values. Values are expanded with single-brace bundle tokens before being exposed to double-brace placeholders in the generated script body. Inserted values are literal and are not rendered recursively.", + "additionalProperties": { "type": "string" } + }, + "Overwrite": { + "type": "boolean", + "description": "When false, an existing generated script is an error rather than a silent skip." + }, + "SignProfile": { "type": ["string", "null"] }, + "Sign": { "anyOf": [{ "$ref": "#/$defs/DotNetPublishSignOptions" }, { "type": "null" }] }, + "SignOverrides": { "anyOf": [{ "$ref": "#/$defs/DotNetPublishSignPatch" }, { "type": "null" }] } + }, + "required": ["OutputPath"], + "anyOf": [ + { "required": ["TemplatePath"] }, + { "required": ["Template"] } + ] + }, "DotNetPublishBundleScript": { "type": "object", "additionalProperties": false, @@ -417,7 +488,11 @@ "Path": { "type": "string", "minLength": 1 }, "Arguments": { "type": "array", "items": { "type": "string" } }, "WorkingDirectory": { "type": ["string", "null"] }, - "TimeoutSeconds": { "type": "integer", "minimum": 1 }, + "TimeoutSeconds": { + "type": "integer", + "minimum": 1, + "description": "Maximum bundle script runtime in seconds. Defaults to 600 when omitted." + }, "PreferPwsh": { "type": "boolean" }, "Required": { "type": "boolean" } }, @@ -459,6 +534,13 @@ "type": "array", "items": { "type": "string", "minLength": 1 } }, + "SignPatterns": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "SignProfile": { "type": ["string", "null"] }, + "Sign": { "anyOf": [{ "$ref": "#/$defs/DotNetPublishSignOptions" }, { "type": "null" }] }, + "SignOverrides": { "anyOf": [{ "$ref": "#/$defs/DotNetPublishSignPatch" }, { "type": "null" }] }, "Metadata": { "anyOf": [{ "$ref": "#/$defs/DotNetPublishBundleMetadataOptions" }, { "type": "null" }] } } }, @@ -472,6 +554,7 @@ "Frameworks": { "type": "array", "items": { "type": "string", "minLength": 1 } }, "Styles": { "type": "array", "items": { "$ref": "#/$defs/DotNetPublishStyle" } }, "OutputPath": { "type": ["string", "null"] }, + "PrimarySubdirectory": { "type": ["string", "null"] }, "ClearOutput": { "type": "boolean" }, "Zip": { "type": "boolean" }, "ZipPath": { "type": ["string", "null"] }, @@ -480,6 +563,18 @@ "type": "array", "items": { "$ref": "#/$defs/DotNetPublishBundleInclude" } }, + "CopyItems": { + "type": "array", + "items": { "$ref": "#/$defs/DotNetPublishBundleCopyItem" } + }, + "ModuleIncludes": { + "type": "array", + "items": { "$ref": "#/$defs/DotNetPublishBundleModuleInclude" } + }, + "GeneratedScripts": { + "type": "array", + "items": { "$ref": "#/$defs/DotNetPublishBundleGeneratedScript" } + }, "Scripts": { "type": "array", "items": { "$ref": "#/$defs/DotNetPublishBundleScript" } @@ -488,6 +583,47 @@ }, "required": ["Id", "PrepareFromTarget"] }, + "DotNetPublishCommandHook": { + "type": "object", + "additionalProperties": false, + "properties": { + "Id": { "type": "string", "minLength": 1 }, + "Phase": { "$ref": "#/$defs/DotNetPublishCommandHookPhase" }, + "Command": { "type": "string", "minLength": 1 }, + "Arguments": { + "type": "array", + "items": { "type": "string" } + }, + "WorkingDirectory": { "type": ["string", "null"] }, + "Environment": { + "type": ["object", "null"], + "additionalProperties": { "type": "string" } + }, + "TimeoutSeconds": { + "type": "integer", + "minimum": 0, + "description": "Maximum hook runtime in seconds. Defaults to 600 when omitted or set to 0." + }, + "Required": { "type": "boolean" }, + "Targets": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "Runtimes": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "Frameworks": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "Styles": { + "type": "array", + "items": { "$ref": "#/$defs/DotNetPublishStyle" } + } + }, + "required": ["Id", "Phase", "Command"] + }, "DotNetPublishProfile": { "type": "object", "additionalProperties": false, diff --git a/Schemas/powerforge.pipelinespec.schema.json b/Schemas/powerforge.pipelinespec.schema.json index 843d92a2..0715a354 100644 --- a/Schemas/powerforge.pipelinespec.schema.json +++ b/Schemas/powerforge.pipelinespec.schema.json @@ -22,6 +22,26 @@ "Roots": { "type": ["array", "null"], "items": { "type": "string" } } } }, + "Diagnostics": { + "type": "object", + "additionalProperties": false, + "properties": { + "BaselinePath": { "type": [ "string", "null" ] }, + "GenerateBaseline": { "type": "boolean" }, + "UpdateBaseline": { "type": "boolean" }, + "FailOnNewDiagnostics": { "type": "boolean" }, + "FailOnSeverity": { + "anyOf": [ + { "type": "string", "enum": [ "Info", "Warning", "Error" ] }, + { "type": "null" } + ] + }, + "BinaryConflictSearchRoots": { + "type": "array", + "items": { "type": "string" } + } + } + }, "Segments": { "type": "array", "items": { "$ref": "powerforge.segments.schema.json#/$defs/ConfigurationSegment" } diff --git a/Schemas/powerforge.plugins.schema.json b/Schemas/powerforge.plugins.schema.json new file mode 100644 index 00000000..b7a91044 --- /dev/null +++ b/Schemas/powerforge.plugins.schema.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "powerforge.plugins.schema.json", + "title": "PowerForge Plugin CatalogSpec (schema v1)", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { "type": "string" }, + "ProjectRoot": { "type": ["string", "null"] }, + "Configuration": { "type": "string", "default": "Release" }, + "Catalog": { + "type": "array", + "items": { "$ref": "#/$defs/PowerForgePluginCatalogEntry" } + } + }, + "required": ["Catalog"], + "$defs": { + "PowerForgePluginCatalogEntry": { + "type": "object", + "additionalProperties": false, + "properties": { + "Id": { "type": "string", "minLength": 1 }, + "ProjectPath": { "type": "string", "minLength": 1 }, + "Groups": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "Framework": { "type": ["string", "null"] }, + "PackageId": { "type": ["string", "null"] }, + "AssemblyName": { "type": ["string", "null"] }, + "MsBuildProperties": { + "type": ["object", "null"], + "additionalProperties": { "type": "string" } + }, + "Manifest": { + "anyOf": [ + { "$ref": "#/$defs/PowerForgePluginManifestOptions" }, + { "type": "null" } + ] + } + }, + "required": ["Id", "ProjectPath"] + }, + "PowerForgePluginManifestOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "Enabled": { "type": "boolean", "default": true }, + "IncludeStandardProperties": { "type": "boolean", "default": true }, + "FileName": { "type": "string", "default": "plugin.manifest.json" }, + "EntryAssembly": { "type": ["string", "null"] }, + "EntryType": { "type": ["string", "null"] }, + "EntryTypeMatchBaseType": { "type": ["string", "null"] }, + "Properties": { + "type": ["object", "null"], + "additionalProperties": { "type": "string" } + } + } + } + } +}