diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3994097..7460c59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,3 +78,82 @@ jobs: path: | dist/selftest dist/kit + + manager: + name: Manager (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + working-directory: manager + env: + TOOL_VERSION: 0.0.0-ci.${{ github.run_number }} + OUT: ../dist/manager + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Install .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Build + run: dotnet build DragoAnt.MSBuildKit.Manager.slnx -c Release -nologo + + - name: Test + run: | + mkdir -p "$OUT" + dotnet test --solution DragoAnt.MSBuildKit.Manager.slnx -c Release --no-build \ + --coverage --coverage-output-format cobertura --report-trx --report-xunit-junit \ + --results-directory "$OUT/TestResults" 2>&1 | tee "$OUT/test.log" + + - name: Prove the tests ran on Microsoft.Testing.Platform + run: | + grep -q 'Test run summary:' "$OUT/test.log" || { echo "dotnet test did not run on Microsoft.Testing.Platform (no MTP summary in the log)"; exit 1; } + echo "dotnet test ran on Microsoft.Testing.Platform (manager/global.json test.runner)" >> "$GITHUB_STEP_SUMMARY" + + - name: Coverage gate (70 %) + run: | + files=$(find "$OUT/TestResults" -name '*.cobertura.xml') + [ -n "$files" ] || { echo "no cobertura report"; exit 1; } + for f in $files; do + rate=$(tr -d '\r' < "$f" | sed -n 's/.*= 0.70) }' + done + + - name: Pack the tool + run: dotnet pack src/DragoAnt.MSBuildKit.Manager/DragoAnt.MSBuildKit.Manager.csproj -c Release -nologo -o "$OUT/feed" -p:Version="$TOOL_VERSION" + + - name: Install and run the packed tool + run: | + dotnet tool install --tool-path "$OUT/tools" --add-source "$OUT/feed" DragoAnt.MSBuildKit.Manager --version "$TOOL_VERSION" + "$OUT/tools/mskit-manager" --help + "$OUT/tools/mskit-manager" status --json > "$OUT/status.json" + cat "$OUT/status.json" + tr -d '\r' < "$OUT/status.json" | grep -q "\"version\": \"$TOOL_VERSION\"" || { echo "status --json did not report $TOOL_VERSION"; exit 1; } + + - name: Test results + if: always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0 + with: + name: Manager tests (${{ matrix.os }}) + path: dist/manager/TestResults/*.trx + reporter: dotnet-trx + + - name: Upload artifacts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: manager-${{ matrix.os }} + path: dist/manager diff --git a/CHANGELOG.md b/CHANGELOG.md index 788b188..4a49c06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project are documented here. The format follows [Kee ## [Unreleased] +### Added + +- `manager/`: the first build of `mskit-manager`, the `DragoAnt.MSBuildKit.Manager` .NET tool (`net8.0`, `net10.0`, `RollForward=Major`) that will install, update and migrate the kit. This build has one command, `status [--json]`, which prints the tool version; the logo goes to stderr, only on a terminal and never with `--no-logo`, so `--json` output always parses. Each run writes a log under `/mskit-manager/logs/`, named after the command, newest 20 kept. Not published yet. + ## [0.2.1] - 2026-10-05 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 77d26d1..daf94dd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,6 +8,7 @@ Issues and pull requests are welcome. - `samples/MinimalLibrary` is a consumer with a library and a test project; its `.toolkit/` is installed by the self-test and not committed. - `tests/run.sh` is the self-test; `tests/fixtures/PackageChecks` breaks every package rule on purpose. - `tools/pack-kit.sh` builds the release zip and its SHA-256. +- `manager/` is the `mskit-manager` .NET tool: its own solution, central package versions and `global.json`, built against `manager/.toolkit/` — the working-tree kit installed with `sh kit/.toolkit/update.sh --source kit --root manager` and committed. ## Build and test @@ -19,6 +20,14 @@ sh tests/run.sh It installs the working-tree kit into the sample and the fixtures with `update.sh`, then checks the computed versions for local, branch, pull-request and tag builds, runs the sample's tests with coverage, inspects the packed nuspec, and asserts that every `MSKIT_PKG` check fires on the fixtures. CI runs the same script on Linux and Windows. +The tool's tests run from `manager/`, so its `global.json` selects Microsoft.Testing.Platform: + +```sh +cd manager && dotnet test --solution DragoAnt.MSBuildKit.Manager.slnx +``` + +CI's `manager` job also packs the tool, installs it into a tool path and runs `mskit-manager --help` and `status --json`. + ## Changing the kit - A new property defaults with `Condition="'$(Name)'==''"`, so a consumer's value always wins, and gets a row in the README. diff --git a/manager/.toolkit/kit.json b/manager/.toolkit/kit.json new file mode 100644 index 0000000..e5e1742 --- /dev/null +++ b/manager/.toolkit/kit.json @@ -0,0 +1,6 @@ +{ + "repository": "DragoAnt/MSBuildKit", + "version": "0.0.0-local", + "sha256": "", + "parts": [] +} diff --git a/manager/.toolkit/kit.parts b/manager/.toolkit/kit.parts new file mode 100644 index 0000000..180cd2b --- /dev/null +++ b/manager/.toolkit/kit.parts @@ -0,0 +1,20 @@ +# DragoAnt.MSBuildKit parts: [required parts...] +# The folder of a part is msbuild/DragoAnt.MSBuildKit. ("Trunk" is msbuild/DragoAnt.MSBuildKit). +Core default +Trunk default +Vcs.GitHub default +TfmConstants default +Packaging default +Testing default +Testing.XUnit.v3 default Testing +Locals.Compile default +Locals.DirectorySecrets default +Locals.Secrets default +PrivateAssets default +Project.RoslynComponent optional +Project.CodeAnalyzer optional Project.RoslynComponent +Project.CodeFixer optional Project.RoslynComponent Project.CodeAnalyzer +Project.SourceGenerator optional Project.RoslynComponent +PackageAsProj optional +ProjMetadata optional +EF optional diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/audit/audit.autodetect-mutex.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/audit/audit.autodetect-mutex.targets new file mode 100644 index 0000000..74fc36e --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/audit/audit.autodetect-mutex.targets @@ -0,0 +1,15 @@ + + + + + + <_MSKit_AutoDetectedTypes>@(MSKit_AutoDetectedProjectType, ', ') + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/audit/audit.roslyn-satellites.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/audit/audit.roslyn-satellites.targets new file mode 100644 index 0000000..d5b0b61 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/audit/audit.roslyn-satellites.targets @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/core.common.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/core.common.props new file mode 100644 index 0000000..cac50d6 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/core.common.props @@ -0,0 +1,32 @@ + + + + false + + True + + + $(Version) + + True + + $(MSBuildThisFileDirectory)../../.local/ + + $([System.IO.Path]::GetDirectoryName('$(SlnxFilePath)').Replace('\', '/').TrimEnd('/'))/ + $(MSBuildThisFileDirectory)../../../ + $([System.IO.Path]::GetFullPath('$(MSKit_SlnFileDirectory)').Replace('\', '/').TrimEnd('/'))/ + + $([System.IO.Path]::GetFileNameWithoutExtension('$(SlnxFilePath)')) + + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../').Replace('\', '/').TrimEnd('/'))/ + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../').Replace('\', '/').TrimEnd('/'))/ + + <_ProjectObjDir>$(BaseIntermediateOutputPath) + <_ProjectObjDir Condition="'$(_ProjectObjDir)'==''">obj + $(_ProjectObjDir) + $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/$(_ProjectObjDir)')) + $([System.IO.Path]::GetFullPath('$(MSKit_ProjectObjDir)').TrimEnd('/').Replace('\', '/')) + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/core.locals.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/core.locals.props new file mode 100644 index 0000000..162da03 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/core.locals.props @@ -0,0 +1,8 @@ + + + + $([System.Environment]::GetFolderPath(SpecialFolder.ApplicationData))\Microsoft\UserSecrets\ + $([System.Environment]::GetFolderPath(SpecialFolder.UserProfile))/.microsoft/usersecrets/ + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/core.locals.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/core.locals.targets new file mode 100644 index 0000000..fcae3d7 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/core.locals.targets @@ -0,0 +1,8 @@ + + + + + $(MSKit_LocalSecretsBaseDirectory)$(UserSecretsId) + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/git.root.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/git.root.props new file mode 100644 index 0000000..74b018a --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/git.root.props @@ -0,0 +1,29 @@ + + + + + $(GITHUB_HEAD_REF) + + + $(GITHUB_REF_NAME) + + + + $([MSBuild]::GetDirectoryNameOfFileAbove('$(MSBuildProjectDirectory)', '.git')) + $([MSBuild]::GetDirectoryNameOfFileAbove('$(MSBuildProjectDirectory)', '.git/HEAD')) + + + + <_MSKit_GitHeadFile Condition="Exists('$(MSKit_GitRoot)/.git/HEAD')">$(MSKit_GitRoot)/.git/HEAD + + <_MSKit_GitDirPointer Condition="'$(_MSKit_GitHeadFile)'=='' AND Exists('$(MSKit_GitRoot)/.git')">$([System.IO.File]::ReadAllText('$(MSKit_GitRoot)/.git').Trim().Replace('gitdir: ', '')) + <_MSKit_GitDirPointer Condition="'$(_MSKit_GitDirPointer)'!='' AND !$([System.IO.Path]::IsPathRooted('$(_MSKit_GitDirPointer)'))">$(MSKit_GitRoot)/$(_MSKit_GitDirPointer) + <_MSKit_GitHeadFile Condition="'$(_MSKit_GitDirPointer)'!='' AND Exists('$(_MSKit_GitDirPointer)/HEAD')">$(_MSKit_GitDirPointer)/HEAD + + + + $([System.IO.File]::ReadAllText('$(_MSKit_GitHeadFile)').Trim().Replace('ref: refs/heads/', '')) + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/git.routine.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/git.routine.props new file mode 100644 index 0000000..2993abe --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/git.routine.props @@ -0,0 +1,12 @@ + + + + $(GITHUB_REF_NAME) + unknown-branch + + ^(main|master|release/.+)$ + false + true + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/init.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/init.props new file mode 100644 index 0000000..edfb4ee --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/init.props @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/init.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/init.targets new file mode 100644 index 0000000..2869ae6 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/init.targets @@ -0,0 +1,5 @@ + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/project-types.autodetect.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/project-types.autodetect.props new file mode 100644 index 0000000..b6ae321 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/project-types.autodetect.props @@ -0,0 +1,46 @@ + + + + + (?<!^DragoAnt\.MSBuildKit\.Project)\.Analyzers$ + (?<!^DragoAnt\.MSBuildKit\.Project)\.CodeFixes$ + (?<!^DragoAnt\.MSBuildKit\.Project)\.SourceGenerator$ + + + + $([System.Text.RegularExpressions.Regex]::IsMatch($(MSBuildProjectName), $(MSKit_CodeAnalyzerProjectNameRegex))) + True + + $([System.Text.RegularExpressions.Regex]::IsMatch($(MSBuildProjectName), $(MSKit_CodeFixerProjectNameRegex))) + True + + $([System.Text.RegularExpressions.Regex]::IsMatch($(MSBuildProjectName), $(MSKit_SourceGeneratorProjectNameRegex))) + true + + + + + <_MSKit_CodeAnalyzerProjectNameRegex_Shared>$(MSKit_CodeAnalyzerProjectNameRegex) + <_MSKit_CodeFixerProjectNameRegex_Shared>$(MSKit_CodeFixerProjectNameRegex) + <_MSKit_SourceGeneratorProjectNameRegex_Shared>$(MSKit_SourceGeneratorProjectNameRegex) + + + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/tfm.routine.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/tfm.routine.props new file mode 100644 index 0000000..0239a0f --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Core/tfm.routine.props @@ -0,0 +1,42 @@ + + + + + <_MSKit_DbpFilePath>$([MSBuild]::GetPathOfFileAbove('Directory.Build.props')) + <_MSKit_DbpText Condition="'$(_MSKit_DbpFilePath)' != '' AND Exists('$(_MSKit_DbpFilePath)')">$([System.IO.File]::ReadAllText('$(_MSKit_DbpFilePath)')) + + <_MSKit_DbpHasTargetFramework Condition="'$(_MSKit_DbpText)' != '' AND ($(_MSKit_DbpText.Contains('<TargetFramework>')) OR $(_MSKit_DbpText.Contains('<TargetFramework ')))">True + <_MSKit_DbpHasTargetFrameworks Condition="'$(_MSKit_DbpText)' != '' AND ($(_MSKit_DbpText.Contains('<TargetFrameworks>')) OR $(_MSKit_DbpText.Contains('<TargetFrameworks ')))">True + + $([System.Text.RegularExpressions.Regex]::Match($(_MSKit_DbpText), '<TargetFramework[^>/]*>([^<]*)</TargetFramework>').Groups[1].Value) + $([System.Text.RegularExpressions.Regex]::Match($(_MSKit_DbpText), '<TargetFrameworks[^>/]*>([^<]*)</TargetFrameworks>').Groups[1].Value) + + + + + <_MSKit_GuardXmlPeekRoutine Condition="'$(MSKit_GuardXmlPeekRoutine)'!='False'">True + <_MSKit_CsprojText Condition="'$(_MSKit_GuardXmlPeekRoutine)'=='True' AND '$(_MSKit_CsprojText)'==''">$([System.IO.File]::ReadAllText('$(MSBuildProjectFullPath)')) + <_MSKit_RoutineHasTargetFramework Condition="'$(_MSKit_GuardXmlPeekRoutine)'!='True'">True + <_MSKit_RoutineHasTargetFramework Condition="'$(_MSKit_GuardXmlPeekRoutine)'=='True' AND ($(_MSKit_CsprojText.Contains('<TargetFramework>')) OR $(_MSKit_CsprojText.Contains('<TargetFramework ')))">True + <_MSKit_RoutineHasTargetFrameworks Condition="'$(_MSKit_GuardXmlPeekRoutine)'!='True'">True + <_MSKit_RoutineHasTargetFrameworks Condition="'$(_MSKit_GuardXmlPeekRoutine)'=='True' AND ($(_MSKit_CsprojText.Contains('<TargetFrameworks>')) OR $(_MSKit_CsprojText.Contains('<TargetFrameworks ')))">True + + + + $([System.Text.RegularExpressions.Regex]::Match($(_MSKit_CsprojText), '<TargetFramework[^>/]*>([^<]*)</TargetFramework>').Groups[1].Value) + $([System.Text.RegularExpressions.Regex]::Match($(_MSKit_CsprojText), '<TargetFrameworks[^>/]*>([^<]*)</TargetFrameworks>').Groups[1].Value) + + + + + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.Compile/init.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.Compile/init.props new file mode 100644 index 0000000..47e2180 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.Compile/init.props @@ -0,0 +1,3 @@ + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.Compile/init.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.Compile/init.targets new file mode 100644 index 0000000..3c6b4c8 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.Compile/init.targets @@ -0,0 +1,3 @@ + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.Compile/local.compile.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.Compile/local.compile.props new file mode 100644 index 0000000..b068b85 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.Compile/local.compile.props @@ -0,0 +1,7 @@ + + + + $(MSKit_Templates)LocalCompile/ + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.Compile/local.compile.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.Compile/local.compile.targets new file mode 100644 index 0000000..77eb087 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.Compile/local.compile.targets @@ -0,0 +1,40 @@ + + + + <_LocalCompileNamespace>$(RootNamespace) + <_LocalCompileNamespace Condition="'$(_LocalCompileNamespace)'==''">$(MSBuildProjectName) + + + + + + + + + + + + + %(Filename) + + + + + + + + + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.DirectorySecrets/init.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.DirectorySecrets/init.props new file mode 100644 index 0000000..effb46d --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.DirectorySecrets/init.props @@ -0,0 +1,9 @@ + + + + True + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.DirectorySecrets/init.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.DirectorySecrets/init.targets new file mode 100644 index 0000000..d2456b2 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.DirectorySecrets/init.targets @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.DirectorySecrets/local.directory.secrets.cleanup.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.DirectorySecrets/local.directory.secrets.cleanup.targets new file mode 100644 index 0000000..eb8172a --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.DirectorySecrets/local.directory.secrets.cleanup.targets @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.DirectorySecrets/local.directory.secrets.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.DirectorySecrets/local.directory.secrets.props new file mode 100644 index 0000000..8dec6d3 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.DirectorySecrets/local.directory.secrets.props @@ -0,0 +1,31 @@ + + + + $(MSKit_SlnFileName) + + False + + + + + $(MSKit_LocalSecretsBaseDirectory)$(MSKit_SlnSecretsId) + $(LocalSlnSecretsDir)/Directory.Secrets.props + + $(MSKit_Templates) + $(LocalSlnSecretsTemplateDir)/Directory.Secrets.props + + + + + + + False + + + + $(LocalSlnSecretsDir)/Directory.Secrets.targets + + $(LocalSlnSecretsTemplateDir)/Directory.Secrets.targets + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.DirectorySecrets/local.directory.secrets.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.DirectorySecrets/local.directory.secrets.targets new file mode 100644 index 0000000..71e24f6 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.DirectorySecrets/local.directory.secrets.targets @@ -0,0 +1,73 @@ + + + + + + + Directory.Secrets.props + true + + + Directory.Secrets.targets + true + + + + + <Project> +</Project> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.Secrets/init.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.Secrets/init.props new file mode 100644 index 0000000..b796e6c --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.Secrets/init.props @@ -0,0 +1,7 @@ + + + + True + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.Secrets/init.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.Secrets/init.targets new file mode 100644 index 0000000..ce7b418 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.Secrets/init.targets @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.Secrets/local.secrets.cleanup.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.Secrets/local.secrets.cleanup.targets new file mode 100644 index 0000000..ed00d8e --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.Secrets/local.secrets.cleanup.targets @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.Secrets/local.secrets.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.Secrets/local.secrets.props new file mode 100644 index 0000000..0de50b5 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.Secrets/local.secrets.props @@ -0,0 +1,10 @@ + + + + $(LocalSecretsDir)/secrets.json + + $(MSKit_Templates) + $(LocalSecretsTemplateDir)/$(UserSecretsId).secrets.json + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.Secrets/local.secrets.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.Secrets/local.secrets.targets new file mode 100644 index 0000000..dcce4ad --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Locals.Secrets/local.secrets.targets @@ -0,0 +1,61 @@ + + + + appsettings + + false + + { +} + + + + + + + $(UserSecretsLinkNamePrefix).UserSecrets.json + + Always + + Always + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/audit/audit.package.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/audit/audit.package.targets new file mode 100644 index 0000000..563bc1e --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/audit/audit.package.targets @@ -0,0 +1,126 @@ + + + + + + True + 30 + ^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ + 128 + <_MSKit_PkgRules>https://learn.microsoft.com/nuget/create-packages/package-authoring-best-practices + <_MSKit_PkgReadmeRules>https://learn.microsoft.com/nuget/nuget-org/package-readme-on-nuget-org + + + + + + + + + + + + + + + + + + <_MSKit_Description>$(PackageDescription) + <_MSKit_Description Condition="'$(_MSKit_Description)'==''">$(Description) + <_MSKit_Description>$(_MSKit_Description.Trim()) + <_MSKit_DescriptionIsPlaceholder Condition="'$(_MSKit_Description)'=='' OR '$(_MSKit_Description)'=='Package Description' OR '$(_MSKit_Description)'=='$(PackageId)' OR '$(_MSKit_Description)'=='$(MSBuildProjectName)'">True + <_MSKit_PkgVersion>$(PackageVersion) + <_MSKit_PkgVersion Condition="'$(_MSKit_PkgVersion)'==''">$(Version) + <_MSKit_RepoUrlFinal>$(RepositoryUrl) + <_MSKit_RepoUrlFinal Condition="'$(_MSKit_RepoUrlFinal)'==''">$(PrivateRepositoryUrl) + <_MSKit_LicenseIsOpen Condition="'$(PackageLicenseExpression)'!=''">True + + + + <_MSKit_PkgFinding Include="MSKIT_PKG001" Condition="'$(_MSKit_DescriptionIsPlaceholder)'=='True'" + Text="Package '$(PackageId)' has no real Description ('$(_MSKit_Description)'). nuget.org search shows it first: say what the package does and what sets it apart, in one or two sentences." + Rule="$(_MSKit_PkgRules)#description" /> + <_MSKit_PkgFinding Include="MSKIT_PKG002" Condition="'$(_MSKit_DescriptionIsPlaceholder)'!='True' AND $(_MSKit_Description.Length) < $(MSKit_PackageDescriptionMinLength)" + Text="Package '$(PackageId)' Description is $(_MSKit_Description.Length) characters ('$(_MSKit_Description)'); write at least $(MSKit_PackageDescriptionMinLength): what it does and for whom (MSKit_PackageDescriptionMinLength sets the bar)." + Rule="$(_MSKit_PkgRules)#description" /> + <_MSKit_PkgFinding Include="MSKIT_PKG003" Condition="'$(PackageReadmeFile)'==''" + Text="Package '$(PackageId)' ships no README. Add package.readme.md next to the csproj: what it is, how to start, a short example, where to give feedback." + Rule="$(_MSKit_PkgRules)#readme" /> + <_MSKit_PkgFinding Include="MSKIT_PKG004" Condition="'$(PackageTags)'==''" + Text="Package '$(PackageId)' has no PackageTags. Add a few search terms that are not already in the package id." + Rule="$(_MSKit_PkgRules)#tags" /> + <_MSKit_PkgFinding Include="MSKIT_PKG005" Condition="'$(PackageIcon)'==''" + Text="Package '$(PackageId)' has no icon. Set PackageIconPath to a 128x128 PNG (the company layer of the kit sets one for every package)." + Rule="$(_MSKit_PkgRules)#icon" /> + <_MSKit_PkgFinding Include="MSKIT_PKG006" Condition="'$(PackageLicenseExpression)'=='' AND '$(PackageLicenseFile)'==''" + Text="Package '$(PackageId)' has no licence. Without one nobody may use it. Set PackageLicenseExpression to an SPDX id (for example MIT)." + Rule="$(_MSKit_PkgRules)#licensing" /> + <_MSKit_PkgFinding Include="MSKIT_PKG007" Condition="'$(PackageLicenseUrl)'!=''" + Text="Package '$(PackageId)' uses the deprecated PackageLicenseUrl. Replace it with PackageLicenseExpression (an SPDX id) or PackageLicenseFile." + Rule="$(_MSKit_PkgRules)#licensing" /> + <_MSKit_PkgFinding Include="MSKIT_PKG008" Condition="'$(PackageIconUrl)'!=''" + Text="Package '$(PackageId)' uses the deprecated PackageIconUrl. Pack the image and point PackageIcon (or the kit's PackageIconPath) at it." + Rule="$(_MSKit_PkgRules)#icon" /> + <_MSKit_PkgFinding Include="MSKIT_PKG009" Condition="'$(_MSKit_RelativeImages)'!=''" + Text="The package README of '$(PackageId)' has relative images, which nuget.org does not render: $(_MSKit_RelativeImages). Use absolute https URLs from an allowed host." + Rule="$(_MSKit_PkgReadmeRules)#allowed-domains-for-images-and-badges" /> + <_MSKit_PkgFinding Include="MSKIT_PKG010" Condition="'$(_MSKit_HtmlTags)'!=''" + Text="The package README of '$(PackageId)' contains HTML, which nuget.org does not render: $(_MSKit_HtmlTags). Use Markdown instead." + Rule="$(_MSKit_PkgReadmeRules)#supported-markdown-features" /> + <_MSKit_PkgFinding Include="MSKIT_PKG011" Condition="'$(_MSKit_Alerts)'!=''" + Text="The package README of '$(PackageId)' uses GitHub alert blocks, which nuget.org shows as plain quotes: $(_MSKit_Alerts). Use a bold lead-in such as **Note:** instead." + Rule="$(_MSKit_PkgReadmeRules)#supported-markdown-features" /> + <_MSKit_PkgFinding Include="MSKIT_PKG012" Condition="'$(_MSKit_DisallowedImages)'!=''" + Text="The package README of '$(PackageId)' loads images from hosts nuget.org blocks: $(_MSKit_DisallowedImages). Host them on an allowed domain (img.shields.io, raw.githubusercontent.com, ...)." + Rule="$(_MSKit_PkgReadmeRules)#allowed-domains-for-images-and-badges" /> + <_MSKit_PkgFinding Include="MSKIT_PKG013" Condition="'$(_MSKit_PkgVersion)'!='' AND !$([System.Text.RegularExpressions.Regex]::IsMatch('$(_MSKit_PkgVersion)', '$(MSKit_SemVerRegex)'))" + Text="Package '$(PackageId)' version '$(_MSKit_PkgVersion)' is not SemVer 2.0 (MAJOR.MINOR.PATCH[-prerelease][+metadata])." + Rule="$(_MSKit_PkgRules)#package-version" /> + <_MSKit_PkgFinding Include="MSKIT_PKG014" Condition="'$(_MSKit_RepoUrlFinal)'=='' OR '$(PackageProjectUrl)'==''" + Text="Package '$(PackageId)' has no repository or project URL. Set RepositoryUrl, or build from a git clone whose origin remote Source Link can read." + Rule="$(_MSKit_PkgRules)#repository-type-and-url" /> + <_MSKit_PkgFinding Include="MSKIT_PKG015" Condition="'$(MSKit_PackageIconSourcePath)'!='' AND Exists('$(MSKit_PackageIconSourcePath)') AND ('$(_MSKit_IconIsPng)'!='True' OR '$(_MSKit_IconWidth)'!='$(MSKit_PackageIconSize)' OR '$(_MSKit_IconHeight)'!='$(MSKit_PackageIconSize)')" + Text="Package '$(PackageId)' icon is not a $(MSKit_PackageIconSize)x$(MSKit_PackageIconSize) PNG (PNG: $(_MSKit_IconIsPng), size: $(_MSKit_IconWidth)x$(_MSKit_IconHeight))." + Rule="$(_MSKit_PkgRules)#icon" /> + <_MSKit_PkgFinding Include="MSKIT_PKG016" Condition="'$(PackageReleaseNotes)'==''" + Text="Package '$(PackageId)' has no PackageReleaseNotes. Set them, or a link to the changelog (the kit links the releases page by default on GitHub, and on any host the generated readme knows with MSKit_PackageReadmeFrom)." + Rule="$(_MSKit_PkgRules)#release-notes" /> + <_MSKit_PkgFinding Include="MSKIT_PKG017" Condition="'$(_MSKit_RelativeLinks)'!=''" + Text="The package README of '$(PackageId)' has relative links, which break on nuget.org: $(_MSKit_RelativeLinks). Use absolute URLs." + Rule="$(_MSKit_PkgReadmeRules)" /> + <_MSKit_PkgFinding Include="MSKIT_PKG018" Condition="'$(_MSKit_LicenseIsOpen)'=='True' AND $(Copyright.ToLowerInvariant().Contains('all rights reserved'))" + Text="Package '$(PackageId)' is licensed '$(PackageLicenseExpression)' but its Copyright says 'all rights reserved' ('$(Copyright)'). Use 'Copyright (c) YEAR OWNER'." + Rule="$(_MSKit_PkgRules)#copyright" /> + <_MSKit_PkgFinding Include="MSKIT_PKG019" Condition="'$(_MSKit_HasMermaid)'=='True'" + Text="The package README of '$(PackageId)' contains a Mermaid diagram, which nuget.org shows as code. Link to the diagram on GitHub instead." + Rule="$(_MSKit_PkgReadmeRules)#supported-markdown-features" /> + + + + <_MSKit_PkgSkip Include="$(MSKit_SkipPackageChecks)" /> + <_MSKit_PkgSkip Include="$(NoWarn)" /> + <_MSKit_PkgFinding Remove="@(_MSKit_PkgSkip)" /> + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/init.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/init.props new file mode 100644 index 0000000..91ed9f6 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/init.props @@ -0,0 +1,27 @@ + + + + + + true + true + true + + True + + true + + true + all + low + + + auto + auto + + auto + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/init.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/init.targets new file mode 100644 index 0000000..1d52011 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/init.targets @@ -0,0 +1,5 @@ + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/nuget.allowed-image-hosts.txt b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/nuget.allowed-image-hosts.txt new file mode 100644 index 0000000..c4a2e1d --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/nuget.allowed-image-hosts.txt @@ -0,0 +1,41 @@ +# Hosts nuget.org renders package-readme images and badges from, one per line. +# Source: https://learn.microsoft.com/nuget/nuget-org/package-readme-on-nuget-org#allowed-domains-for-images-and-badges +# (page updated 2026-07-07, read 2026-10-05). Refreshed with kit releases; never fetched at build time. +# github.com is allowed only for Actions workflow badges (github.com/.../workflows/.../badge.svg), a path the check handles itself. +api.codacy.com +api.codeclimate.com +api.dependabot.com +api.reuse.software +api.travis-ci.com +app.codacy.com +app.deepsource.com +avatars.githubusercontent.com +badgen.net +badges.gitter.im +camo.githubusercontent.com +caniuse.bitsofco.de +cdn.jsdelivr.net +cdn.syncfusion.com +ci.appveyor.com +circleci.com +cloudback.it +codecov.io +codefactor.io +coveralls.io +dev.azure.com +devpod.sh +flat.badgen.net +gitlab.com +i.imgur.com +img.shields.io +infragistics.com +isitmaintained.com +media.githubusercontent.com +opencollective.com +raw.github.com +raw.githubusercontent.com +snyk.io +sonarcloud.io +travis-ci.com +travis-ci.org +user-images.githubusercontent.com diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/package.inspect.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/package.inspect.targets new file mode 100644 index 0000000..bdb9dd4 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/package.inspect.targets @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/package.readme.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/package.readme.targets new file mode 100644 index 0000000..38995aa --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/package.readme.targets @@ -0,0 +1,141 @@ + + + + + + $(MSBuildThisFileDirectory)nuget.allowed-image-hosts.txt + <_MSKit_ReadmeStampPath>$(MSKit_GeneratedPackageReadmePath).inputs + <_MSKit_ReadmeFindingsBase>$(MSKit_GeneratedPackageReadmePath).findings + <_MSKit_ReadmeReleasesUrlPath>$(MSKit_GeneratedPackageReadmePath).releases + + + + + + + + <_MSKit_AllowedImageHost Include="@(_MSKit_AllowedImageHostLine)" Condition="!$([System.String]::Copy('%(_MSKit_AllowedImageHostLine.Identity)').StartsWith('#'))" /> + + + <_MSKit_AllowedImageHosts>$(MSKit_PackageReadmeAllowedImageHosts) + <_MSKit_AllowedImageHosts Condition="'$(_MSKit_AllowedImageHosts)'==''">@(_MSKit_AllowedImageHost) + + + + + + + + + + + + + <_MSKit_ReadmeCommit>$(RepositoryCommit) + <_MSKit_ReadmeCommit Condition="'$(_MSKit_ReadmeCommit)'==''">$(SourceRevisionId) + <_MSKit_ReadmeCommit Condition="'$(_MSKit_ReadmeCommit)'==''">$(MSKit_CommitSha) + <_MSKit_ReadmeRepoRoot>$(MSKit_GitRoot) + <_MSKit_ReadmeRepoRoot Condition="'$(_MSKit_ReadmeRepoRoot)'==''">$([System.IO.Path]::GetDirectoryName('$(_MSKit_ReadmeFromPath)')) + <_MSKit_RepositoryVisibility>$(MSKit_RepositoryVisibility) + <_MSKit_RepositoryVisibility Condition="'$(_MSKit_RepositoryVisibility)'==''">$(CI_PROJECT_VISIBILITY) + + + <_MSKit_ReadmeStampLine Include="package=$(PackageId)" /> + <_MSKit_ReadmeStampLine Include="source=$(_MSKit_ReadmeFromPath)" /> + <_MSKit_ReadmeStampLine Include="root=$(_MSKit_ReadmeRepoRoot)" /> + <_MSKit_ReadmeStampLine Include="url=$(_MSKit_RepoUrl)" /> + <_MSKit_ReadmeStampLine Include="commit=$(_MSKit_ReadmeCommit)" /> + <_MSKit_ReadmeStampLine Include="provider=$(MSKit_RepoProvider)" /> + <_MSKit_ReadmeStampLine Include="blob=$(MSKit_RepoBlobUrlTemplate)" /> + <_MSKit_ReadmeStampLine Include="raw=$(MSKit_RepoRawUrlTemplate)" /> + <_MSKit_ReadmeStampLine Include="releases=$(MSKit_ReleasesUrl)" /> + <_MSKit_ReadmeStampLine Include="issues=$(MSKit_IssuesUrl)" /> + <_MSKit_ReadmeStampLine Include="title=$(MSKit_PackageReadmeTitle)" /> + <_MSKit_ReadmeStampLine Include="visibility=$(_MSKit_RepositoryVisibility)" /> + <_MSKit_ReadmeStampLine Include="hosts=$(_MSKit_AllowedImageHosts)" /> + <_MSKit_ReadmeStampLine Include="@(SourceLinkGitHubHost->'github=%(Identity)')" /> + <_MSKit_ReadmeStampLine Include="@(SourceLinkGitLabHost->'gitlab=%(Identity)')" /> + <_MSKit_ReadmeStampLine Include="@(SourceLinkAzureReposHost->'azure=%(Identity)')" /> + <_MSKit_ReadmeStampLine Include="@(SourceLinkBitbucketGitHost->'bitbucket=%(Identity)')" /> + <_MSKit_ReadmeStampLine Include="@(SourceLinkGiteaHost->'gitea=%(Identity)')" /> + + + + + + + + + + + + + + + + $(_MSKit_ReadmeReleasesUrl) + + + + + + + <_MSKit_ReadmeCode Include="MSKIT_PKG020;MSKIT_PKG021;MSKIT_PKG022" /> + <_MSKit_ReadmeSkip Include="$(MSKit_SkipPackageChecks);$(NoWarn)" /> + <_MSKit_ReadmeCode Remove="@(_MSKit_ReadmeSkip)" /> + + + <_MSKit_ReadmeCodes>;@(_MSKit_ReadmeCode); + + + + + + + + + + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/package.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/package.targets new file mode 100644 index 0000000..9b2c60d --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/package.targets @@ -0,0 +1,96 @@ + + + + + + $(MSBuildProjectName) + false + git + $(RepositoryUrl) + + + true + false + snupkg + + + true + $(MSKit_PackageValidationBaselineVersion) + + + + + <_MSKit_ReadmeFromPath Condition="$([System.IO.Path]::IsPathRooted('$(MSKit_PackageReadmeFrom)'))">$([MSBuild]::NormalizePath('$(MSKit_PackageReadmeFrom)')) + <_MSKit_ReadmeFromPath Condition="'$(_MSKit_ReadmeFromPath)'=='' AND '$(MSKit_GitRoot)'!=''">$([MSBuild]::NormalizePath('$(MSKit_GitRoot)', '$(MSKit_PackageReadmeFrom)')) + <_MSKit_ReadmeFromPath Condition="'$(_MSKit_ReadmeFromPath)'==''">$([MSBuild]::NormalizePath('$(MSBuildProjectDirectory)', '$(MSKit_PackageReadmeFrom)')) + $([MSBuild]::NormalizePath('$(MSBuildProjectDirectory)', '$(BaseIntermediateOutputPath)', '$(Configuration)', 'package.readme.md')) + + + <_MSKit_GeneratesReadme>True + readme.md + $(MSKit_GeneratedPackageReadmePath) + <_MSKit_PacksReadme>True + + + + + $(MSBuildProjectDirectory)/package.readme.md + $(MSBuildProjectDirectory)/README.md + readme.md + <_MSKit_PacksReadme Condition="'$(PackageReadmeFile)'!=''">True + + + $(MSBuildProjectDirectory)/$(PackageReadmeFile) + + + + + icon.png + <_MSKit_PacksIcon>True + + + $(PackageIconPath) + $(MSBuildProjectDirectory)/$(PackageIcon) + + + + + + + + + + + + + + <_MSKit_ResolvePackageUrlsDependsOn Condition="'$(SourceControlInformationFeatureSupported)'=='true'">InitializeSourceControlInformation + + + + <_MSKit_RepoUrl>$(RepositoryUrl) + <_MSKit_RepoUrl Condition="'$(_MSKit_RepoUrl)'==''">$(PrivateRepositoryUrl) + <_MSKit_RepoUrl Condition="$(_MSKit_RepoUrl.EndsWith('.git'))">$(_MSKit_RepoUrl.Substring(0, $([MSBuild]::Subtract($(_MSKit_RepoUrl.Length), 4)))) + $(_MSKit_RepoUrl) + + <_MSKit_IsGitHubUrl Condition="$(PackageProjectUrl.StartsWith('https://github.com/'))">True + $(PackageProjectUrl)/releases/tag/$(MSKit_ReleaseTagRaw) + $(PackageProjectUrl)/releases + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/package.tasks.cs.txt b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/package.tasks.cs.txt new file mode 100644 index 0000000..d0d5ddf --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Packaging/package.tasks.cs.txt @@ -0,0 +1,446 @@ +// Inline MSBuild tasks of the Packaging part, compiled by the RoslynCodeTaskFactory that ships with +// MSBuild. One task with two actions, so a pack compiles it once. The .txt extension keeps it out of +// a project's default Compile glob. +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +public sealed class MSKit_PackageAssets : Task +{ + [Required] public string Mode { get; set; } + + public override bool Execute() + { + if (Mode == "Inspect") Inspect(); + else if (Mode == "GenerateReadme") GenerateReadme(); + else Log.LogError("MSKit_PackageAssets: unknown Mode '" + Mode + "'."); + return !Log.HasLoggedErrors; + } + + public string ReadmePath { get; set; } + public string IconPath { get; set; } + public string AllowedImageHosts { get; set; } + [Output] public string RelativeImages { get; set; } + [Output] public string DisallowedImages { get; set; } + [Output] public string RelativeLinks { get; set; } + [Output] public string HtmlTags { get; set; } + [Output] public string Alerts { get; set; } + [Output] public bool HasMermaid { get; set; } + [Output] public bool IconIsPng { get; set; } + [Output] public int IconWidth { get; set; } + [Output] public int IconHeight { get; set; } + + private void Inspect() + { + var relativeImages = new List(); + var disallowedImages = new List(); + var relativeLinks = new List(); + var htmlTags = new List(); + var alerts = new List(); + + if (!string.IsNullOrEmpty(ReadmePath) && File.Exists(ReadmePath)) + { + var text = File.ReadAllText(ReadmePath); + HasMermaid = Regex.IsMatch(text, @"^\s*(```|~~~)\s*mermaid\b", RegexOptions.Multiline | RegexOptions.IgnoreCase); + + var prose = Regex.Replace(text, @"^\s*(```|~~~)[\s\S]*?^\s*\1\s*$", "", RegexOptions.Multiline); + prose = Regex.Replace(prose, @"`[^`\r\n]*`", ""); + + var hosts = (AllowedImageHosts ?? "").Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries) + .Select(h => h.Trim().ToLowerInvariant()).Where(h => h.Length > 0).ToList(); + + var images = new List(); + foreach (Match m in Regex.Matches(prose, @"!\[[^\]]*\]\(\s*]+)")) + images.Add(m.Groups[1].Value); + foreach (Match m in Regex.Matches(prose, @"]*\bsrc\s*=\s*[""']([^""']+)[""']", RegexOptions.IgnoreCase)) + images.Add(m.Groups[1].Value); + + foreach (var url in images.Distinct()) + { + Uri uri; + if (!Uri.TryCreate(url, UriKind.Absolute, out uri) || (uri.Scheme != "https" && uri.Scheme != "http")) + { + relativeImages.Add(url); + continue; + } + var host = uri.Host.ToLowerInvariant(); + var allowed = hosts.Contains(host) + || (host == "github.com" && Regex.IsMatch(uri.AbsolutePath, @"^/[^/]+/[^/]+/(actions/)?workflows/[^/]+/badge\.svg$", RegexOptions.IgnoreCase)); + if (!allowed) + disallowedImages.Add(url); + } + + foreach (Match m in Regex.Matches(prose, @"(?]+)")) + { + var target = m.Groups[1].Value; + if (target.StartsWith("#") || Regex.IsMatch(target, @"^[A-Za-z][A-Za-z0-9+.-]*:")) + continue; + relativeLinks.Add(target); + } + + foreach (Match m in Regex.Matches(prose, @"]*)?/?>")) + { + if (m.Value.Contains("://") || m.Value.Contains("@")) + continue; + htmlTags.Add("<" + m.Groups[1].Value.ToLowerInvariant() + ">"); + } + + foreach (Match m in Regex.Matches(prose, @"^\s*>\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]", RegexOptions.Multiline | RegexOptions.IgnoreCase)) + alerts.Add("[!" + m.Groups[1].Value.ToUpperInvariant() + "]"); + } + + if (!string.IsNullOrEmpty(IconPath) && File.Exists(IconPath)) + { + var header = new byte[24]; + using (var stream = File.OpenRead(IconPath)) + { + var read = stream.Read(header, 0, header.Length); + IconIsPng = read == 24 && header[0] == 0x89 && header[1] == 0x50 && header[2] == 0x4E && header[3] == 0x47; + } + if (IconIsPng) + { + IconWidth = (header[16] << 24) | (header[17] << 16) | (header[18] << 8) | header[19]; + IconHeight = (header[20] << 24) | (header[21] << 16) | (header[22] << 8) | header[23]; + } + } + + RelativeImages = string.Join(", ", relativeImages.Distinct()); + DisallowedImages = string.Join(", ", disallowedImages.Distinct()); + RelativeLinks = string.Join(", ", relativeLinks.Distinct()); + HtmlTags = string.Join(", ", htmlTags.Distinct()); + Alerts = string.Join(", ", alerts.Distinct()); + } + + public string OutputPath { get; set; } + public string FindingsBase { get; set; } + public string ReleasesUrlPath { get; set; } + public string RepoRoot { get; set; } + public string PackageId { get; set; } + public string RepositoryUrl { get; set; } + public string Commit { get; set; } + public string Provider { get; set; } + public string BlobTemplate { get; set; } + public string RawTemplate { get; set; } + public string ReleasesUrl { get; set; } + public string IssuesUrl { get; set; } + public string ReadmeTitle { get; set; } + public string Visibility { get; set; } + public string GitHubHosts { get; set; } + public string GitLabHosts { get; set; } + public string AzureReposHosts { get; set; } + public string BitbucketHosts { get; set; } + public string GiteaHosts { get; set; } + + private void GenerateReadme() + { + var codes =new[] { "MSKIT_PKG020", "MSKIT_PKG021", "MSKIT_PKG022" }; + var findings = codes.ToDictionary(c => c, c => new List()); + Action report = (code, text) => findings[code].Add(text.Replace("\r", " ").Replace("\n", " ")); + + Func norm = s => (s ?? "").Trim(); + Func> hostSet = list => new HashSet( + norm(list).Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries) + .Select(h => Regex.Replace(h.Trim().ToLowerInvariant(), @"^[a-z][a-z0-9+.-]*://", "")) + .Select(h => h.Split('/')[0].Split(':')[0]) + .Where(h => h.Length > 0)); + + var root = Path.GetFullPath(string.IsNullOrWhiteSpace(RepoRoot) ? Path.GetDirectoryName(ReadmePath) : RepoRoot).TrimEnd('\\', '/'); + var readmeFull = Path.GetFullPath(ReadmePath); + var readmeDir = Path.GetDirectoryName(readmeFull); + var readmeRel = ""; + if (readmeDir.Length > root.Length && readmeDir.StartsWith(root, StringComparison.OrdinalIgnoreCase) && (readmeDir[root.Length] == '\\' || readmeDir[root.Length] == '/')) + readmeRel = readmeDir.Substring(root.Length + 1).Replace('\\', '/'); + var display = (readmeRel.Length > 0 ? readmeRel + "/" : "") + Path.GetFileName(readmeFull); + + var repoUrl = norm(RepositoryUrl); + var m0 = Regex.Match(repoUrl, @"^[^@/\s]+@([^:/\s]+):(.+)$"); + if (m0.Success) repoUrl = "https://" + m0.Groups[1].Value + "/" + m0.Groups[2].Value; + if (repoUrl.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) repoUrl = repoUrl.Substring(0, repoUrl.Length - 4); + repoUrl = repoUrl.TrimEnd('/'); + string host = "", repoPath = "", owner = "", repo = ""; + Uri repoUri; + var hasRepo = Uri.TryCreate(repoUrl, UriKind.Absolute, out repoUri) && repoUri.Host.Length > 0; + if (hasRepo) + { + host = repoUri.Host.ToLowerInvariant(); + repoPath = repoUri.AbsolutePath.Trim('/'); + var scheme = repoUri.Scheme == "http" ? "http" : "https"; + repoUrl = scheme + "://" + repoUri.Authority + (repoPath.Length > 0 ? "/" + repoPath : ""); + var cut = repoPath.LastIndexOf('/'); + owner = cut > 0 ? repoPath.Substring(0, cut) : ""; + repo = cut >= 0 ? repoPath.Substring(cut + 1) : repoPath; + } + + var provider = norm(Provider); + if (provider.Length == 0 && hasRepo) + { + if (host == "github.com" || hostSet(GitHubHosts).Contains(host)) provider = "GitHub"; + else if (host == "gitlab.com" || hostSet(GitLabHosts).Contains(host)) provider = "GitLab"; + else if (host == "dev.azure.com" || host.EndsWith(".visualstudio.com") || hostSet(AzureReposHosts).Contains(host)) provider = "AzureDevOps"; + else if (host == "bitbucket.org" || hostSet(BitbucketHosts).Contains(host)) provider = "Bitbucket"; + else if (host == "codeberg.org" || host == "gitea.com" || hostSet(GiteaHosts).Contains(host)) provider = "Gitea"; + } + + string blob = "", tree = "", raw = "", releases = "", issues = ""; + switch (provider.ToLowerInvariant()) + { + case "github": + blob = "{repoUrl}/blob/{commit}/{path}"; tree = "{repoUrl}/tree/{commit}/{path}"; + raw = host == "github.com" ? "https://raw.githubusercontent.com/{repoPath}/{commit}/{path}" : "{repoUrl}/raw/{commit}/{path}"; + releases = "{repoUrl}/releases"; issues = "{repoUrl}/issues"; + break; + case "gitlab": + blob = "{repoUrl}/-/blob/{commit}/{path}"; tree = "{repoUrl}/-/tree/{commit}/{path}"; raw = "{repoUrl}/-/raw/{commit}/{path}"; + releases = "{repoUrl}/-/releases"; issues = "{repoUrl}/-/issues"; + break; + case "azuredevops": + blob = tree = raw = "{repoUrl}?path=/{path}&version=GC{commit}"; + break; + case "bitbucket": + blob = tree = "{repoUrl}/src/{commit}/{path}"; raw = "{repoUrl}/raw/{commit}/{path}"; issues = "{repoUrl}/issues"; + break; + case "gitea": + blob = tree = "{repoUrl}/src/commit/{commit}/{path}"; raw = "{repoUrl}/raw/commit/{commit}/{path}"; + releases = "{repoUrl}/releases"; issues = "{repoUrl}/issues"; + break; + case "": + break; + default: + report("MSKIT_PKG020", display + ": MSKit_RepoProvider '" + provider + "' is not one of GitHub, GitLab, AzureDevOps, Bitbucket, Gitea; set MSKit_RepoBlobUrlTemplate and MSKit_RepoRawUrlTemplate instead."); + break; + } + if (norm(BlobTemplate).Length > 0) { blob = norm(BlobTemplate); tree = blob.Contains("/blob/") ? blob.Replace("/blob/", "/tree/") : blob; } + if (norm(RawTemplate).Length > 0) raw = norm(RawTemplate); + + var commit = norm(Commit); + string why = null; + if (!hasRepo && norm(BlobTemplate).Length == 0) why = "there is no repository URL (set RepositoryUrl)"; + else if (blob.Length == 0 || raw.Length == 0) why = "the repository host '" + host + "' is not recognised (set MSKit_RepoProvider, add a SourceLinkGitLabHost / SourceLinkGitHubHost item, or set MSKit_RepoBlobUrlTemplate and MSKit_RepoRawUrlTemplate)"; + else if (commit.Length == 0 && (blob.Contains("{commit}") || raw.Contains("{commit}"))) why = "there is no commit to pin them to (RepositoryCommit is empty)"; + var canRewrite = why == null; + var unresolved = 0; + + Func expand = (template, path) => template + .Replace("{repoUrl}", repoUrl).Replace("{host}", host).Replace("{repoPath}", repoPath) + .Replace("{owner}", owner).Replace("{repo}", repo).Replace("{commit}", commit).Replace("{path}", path); + + Func combine = p => + { + var parts = new List(); + if (!p.StartsWith("/")) + parts.AddRange(readmeRel.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries)); + foreach (var s in p.Split('/')) + { + if (s.Length == 0 || s == ".") continue; + if (s == "..") { if (parts.Count == 0) return null; parts.RemoveAt(parts.Count - 1); continue; } + parts.Add(s); + } + return string.Join("/", parts); + }; + + var allowed = hostSet(AllowedImageHosts); + Action checkImage = (url, n) => + { + Uri u; + if (!Uri.TryCreate(url.Trim('<', '>'), UriKind.Absolute, out u) || (u.Scheme != "https" && u.Scheme != "http")) return; + var h = u.Host.ToLowerInvariant(); + if (allowed.Contains(h) || (h == "github.com" && Regex.IsMatch(u.AbsolutePath, @"^/[^/]+/[^/]+/(actions/)?workflows/[^/]+/badge\.svg$", RegexOptions.IgnoreCase))) return; + report("MSKIT_PKG021", display + " line " + n + ": the image " + url + " comes from " + h + ", a host nuget.org does not show images from. Use an allowed host (nuget.allowed-image-hosts.txt in the kit) or point MSKit_RepoRawUrlTemplate at one."); + }; + + Func resolve = (target, isImage, n) => + { + var angle = target.StartsWith("<") && target.EndsWith(">"); + var t = angle ? target.Substring(1, target.Length - 2) : target; + if (t.Length == 0 || t.StartsWith("#") || t.StartsWith("//") || Regex.IsMatch(t, @"^[A-Za-z][A-Za-z0-9+.-]*:")) return target; + var cut = t.IndexOfAny(new[] { '?', '#' }); + var p = cut >= 0 ? t.Substring(0, cut) : t; + var suffix = cut >= 0 ? t.Substring(cut) : ""; + if (p.Length == 0) return target; + if (!canRewrite) { unresolved++; return target; } + var rp = combine(Uri.UnescapeDataString(p)); + if (rp == null) + { + report("MSKIT_PKG020", display + " line " + n + ": " + t + " points outside the repository and is left as it is."); + return target; + } + var isDir = Directory.Exists(Path.Combine(root, rp.Replace('/', Path.DirectorySeparatorChar))); + var encoded = string.Join("/", rp.Split('/').Select(Uri.EscapeDataString)); + var url = expand(isImage ? raw : (isDir ? tree : blob), encoded) + suffix; + return angle ? "<" + url + ">" : url; + }; + + var imageRe = new Regex(@"(!\[[^\[\]]*\]\(\s*)(<[^>]*>|[^)\s]+)"); + var linkRe = new Regex(@"((?]*>|[^)\s]+)"); + var refDefRe = new Regex(@"^(\s{0,3}\[[^\]]+\]:\s*)(<[^>]*>|\S+)(.*)$"); + var imageExtRe = new Regex(@"\.(png|jpe?g|gif|svg|webp|bmp|ico)$", RegexOptions.IgnoreCase); + var codeSpanRe = new Regex(@"(`+)[\s\S]*?(? rewriteProse = (s, n) => + { + s = imageRe.Replace(s, m => { var r = resolve(m.Groups[2].Value, true, n); checkImage(r, n); return m.Groups[1].Value + r; }); + return linkRe.Replace(s, m => m.Groups[1].Value + resolve(m.Groups[2].Value, false, n)); + }; + Func rewriteLine = (line, n) => + { + var rd = refDefRe.Match(line); + if (rd.Success) + { + var target = rd.Groups[2].Value; + var isImage = imageExtRe.IsMatch(target.Trim('<', '>').Split('?', '#')[0]); + var r = resolve(target, isImage, n); + if (isImage) checkImage(r, n); + return rd.Groups[1].Value + r + rd.Groups[3].Value; + } + var sb = new StringBuilder(); + var pos = 0; + foreach (Match m in codeSpanRe.Matches(line)) + { + sb.Append(rewriteProse(line.Substring(pos, m.Index - pos), n)); + sb.Append(m.Value); + pos = m.Index + m.Length; + } + sb.Append(rewriteProse(line.Substring(pos), n)); + return sb.ToString(); + }; + + var skipOpen = new Regex(@"^\s*\s*$"); + var skipClose = new Regex(@"^\s*\s*$"); + var onlyOpen = new Regex(@"^\s*\s*$"); + var onlyClose = new Regex(@"^\s*\s*$"); + var fenceRe = new Regex(@"^\s{0,3}(`{3,}|~{3,})"); + + var lines = File.ReadAllText(readmeFull).Replace("\r\n", "\n").Replace("\r", "\n").Split('\n'); + var outLines = new List(); + var outFence = new List(); + string fence = null; + bool skipping = false, inOnly = false, keepOnly = true; + int skipLine = 0, onlyLine = 0; + for (var i = 0; i < lines.Length; i++) + { + var line = lines[i]; + var n = i + 1; + var drop = skipping || (inOnly && !keepOnly); + if (fence != null) + { + if (Regex.IsMatch(line, @"^\s{0,3}" + Regex.Escape(fence.Substring(0, 1)) + "{" + fence.Length + @",}\s*$")) fence = null; + if (!drop) { outLines.Add(line); outFence.Add(true); } + continue; + } + if (skipOpen.IsMatch(line)) + { + if (skipping) report("MSKIT_PKG020", display + " line " + n + ": nuget:skip opened again before line " + skipLine + "'s was closed."); + skipping = true; skipLine = n; continue; + } + if (skipClose.IsMatch(line)) + { + if (!skipping) report("MSKIT_PKG020", display + " line " + n + ": /nuget:skip has no matching nuget:skip."); + skipping = false; continue; + } + var om = onlyOpen.Match(line); + if (om.Success) + { + if (inOnly) report("MSKIT_PKG020", display + " line " + n + ": nuget:only opened again before line " + onlyLine + "'s was closed."); + inOnly = true; onlyLine = n; + keepOnly = Regex.Split(om.Groups[1].Value.Trim(), @"[\s,]+").Any(id => string.Equals(id, PackageId, StringComparison.OrdinalIgnoreCase)); + continue; + } + if (onlyClose.IsMatch(line)) + { + if (!inOnly) report("MSKIT_PKG020", display + " line " + n + ": /nuget:only has no matching nuget:only."); + inOnly = false; keepOnly = true; continue; + } + var fm = fenceRe.Match(line); + if (fm.Success) + { + fence = fm.Groups[1].Value; + if (!drop) { outLines.Add(line); outFence.Add(true); } + continue; + } + if (drop) continue; + outLines.Add(rewriteLine(line, n)); + outFence.Add(false); + } + if (skipping) report("MSKIT_PKG020", display + " line " + skipLine + ": nuget:skip is never closed, so everything after it is left out."); + if (inOnly) report("MSKIT_PKG020", display + " line " + onlyLine + ": nuget:only is never closed."); + if (!canRewrite && unresolved > 0) + report("MSKIT_PKG020", display + ": " + unresolved + " relative link(s) and image(s) stay relative because " + why + "."); + + var title = norm(ReadmeTitle) == "auto" ? norm(PackageId) : norm(ReadmeTitle); + if (title.Length > 0) + { + var anyAtx = new Regex(@"^ {0,3}#{1,6}(?:[ \t]|$)"); + var h1Atx = new Regex(@"^ {0,3}#(?:[ \t]|$)"); + var setextH1 = new Regex(@"^ {0,3}=+[ \t]*$"); + int from = -1, to = -1; + for (var i = 0; i < outLines.Count && from < 0; i++) + { + if (outFence[i]) continue; + if (h1Atx.IsMatch(outLines[i])) { from = to = i; continue; } + if (i + 1 < outLines.Count && !outFence[i + 1] && outLines[i].Trim().Length > 0 && !anyAtx.IsMatch(outLines[i]) && setextH1.IsMatch(outLines[i + 1])) + { + from = i; to = i + 1; + while (from > 0 && !outFence[from - 1] && outLines[from - 1].Trim().Length > 0 && !anyAtx.IsMatch(outLines[from - 1])) from--; + } + } + if (from >= 0) + { + outLines.RemoveRange(from, to - from + 1); outFence.RemoveRange(from, to - from + 1); + outLines.Insert(from, "# " + title); outFence.Insert(from, false); + } + else + { + outLines.InsertRange(0, new[] { "# " + title, "" }); outFence.InsertRange(0, new[] { false, false }); + } + } + + var releasesUrl = norm(ReleasesUrl) == "auto" ? (hasRepo && releases.Length > 0 ? expand(releases, "") : "") : expand(norm(ReleasesUrl), ""); + var issuesUrl = norm(IssuesUrl) == "auto" ? (hasRepo && issues.Length > 0 ? expand(issues, "") : "") : expand(norm(IssuesUrl), ""); + var start = -1; + var end = outLines.Count; + for (var i = 0; i < outLines.Count; i++) + if (!outFence[i] && Regex.IsMatch(outLines[i], @"^##\s+Overview\s*#*\s*$", RegexOptions.IgnoreCase)) { start = i + 1; break; } + if (start < 0) start = 0; + for (var i = start; i < outLines.Count; i++) + if (!outFence[i] && (start > 0 ? Regex.IsMatch(outLines[i], @"^#{1,2}\s") : Regex.IsMatch(outLines[i], @"^##\s"))) { end = i; break; } + var overview = string.Join("\n", outLines.GetRange(start, end - start)); + var links = new List(); + if (releasesUrl.Length > 0 && !overview.Contains("](" + releasesUrl)) links.Add("[Release notes](" + releasesUrl + ")"); + if (issuesUrl.Length > 0 && !overview.Contains("](" + issuesUrl)) links.Add("[Issues](" + issuesUrl + ")"); + if (links.Count > 0) + { + var at = end; + while (at > start && outLines[at - 1].Trim().Length == 0) at--; + outLines.InsertRange(at, new[] { "", string.Join(" · ", links), "" }); + outFence.InsertRange(at, new[] { false, false, false }); + } + + var visibility = norm(Visibility).ToLowerInvariant(); + if (visibility == "private" || visibility == "internal") + report("MSKIT_PKG022", "The repository " + (repoUrl.Length > 0 ? repoUrl : "of " + PackageId) + " is " + visibility + ": the links in the package readme of " + PackageId + " will not open for people reading it on nuget.org. Publish from a public repository, or point MSKit_RepoBlobUrlTemplate and MSKit_RepoRawUrlTemplate at a public mirror."); + + var result = new List(); + var blank = true; + for (var i = 0; i < outLines.Count; i++) + { + var isBlank = !outFence[i] && outLines[i].Trim().Length == 0; + if (isBlank && blank) continue; + result.Add(outLines[i]); + blank = isBlank; + } + while (result.Count > 0 && result[result.Count - 1].Trim().Length == 0) result.RemoveAt(result.Count - 1); + + var utf8 = new UTF8Encoding(false); + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(OutputPath))); + File.WriteAllText(OutputPath, string.Join("\n", result) + "\n", utf8); + foreach (var code in codes) + File.WriteAllText(FindingsBase + "." + code + ".txt", string.Join("\n", findings[code]) + (findings[code].Count > 0 ? "\n" : ""), utf8); + if (!string.IsNullOrWhiteSpace(ReleasesUrlPath)) + File.WriteAllText(ReleasesUrlPath, releasesUrl.Length > 0 ? releasesUrl + "\n" : "", utf8); + } +} diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.PrivateAssets/init.last.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.PrivateAssets/init.last.targets new file mode 100644 index 0000000..2d938a6 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.PrivateAssets/init.last.targets @@ -0,0 +1,5 @@ + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.PrivateAssets/private.assets.references.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.PrivateAssets/private.assets.references.targets new file mode 100644 index 0000000..9226ef2 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.PrivateAssets/private.assets.references.targets @@ -0,0 +1,14 @@ + + + + + + + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing.XUnit.v3/audit/audit.tests.project.xunit.v3.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing.XUnit.v3/audit/audit.tests.project.xunit.v3.targets new file mode 100644 index 0000000..febe8d5 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing.XUnit.v3/audit/audit.tests.project.xunit.v3.targets @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing.XUnit.v3/implicit.package.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing.XUnit.v3/implicit.package.targets new file mode 100644 index 0000000..29bd17d --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing.XUnit.v3/implicit.package.targets @@ -0,0 +1,26 @@ + + + + 4.0.1 + $(MSKit_PackageVersion_XunitV3) + $(MSKit_PackageVersion_XunitV3) + 4.0.0 + + + + + + + + + + + + $(MSKit_PackageVersion_XunitV3) + $(MSKit_PackageVersion_XunitV3) + $(MSKit_PackageVersion_XunitV3Assert) + $(MSKit_PackageVersion_XunitV3ExtensibilityCore) + $(MSKit_PackageVersion_XunitRunnerVisualStudio) + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing.XUnit.v3/init.last.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing.XUnit.v3/init.last.targets new file mode 100644 index 0000000..ea85fe1 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing.XUnit.v3/init.last.targets @@ -0,0 +1,6 @@ + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing.XUnit.v3/init.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing.XUnit.v3/init.props new file mode 100644 index 0000000..86ddd60 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing.XUnit.v3/init.props @@ -0,0 +1,12 @@ + + + + true + <_MSKit_TestingFramework_xunit_v3_CommonPropsPath>$(MSBuildThisFileDirectory)tests.project.xunit.v3.common.props + <_MSKit_TestingFramework_xunit_v3_LibCommonPropsPath>$(MSBuildThisFileDirectory)tests.lib.project.xunit.v3.common.props + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing.XUnit.v3/init.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing.XUnit.v3/init.targets new file mode 100644 index 0000000..d12608e --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing.XUnit.v3/init.targets @@ -0,0 +1,7 @@ + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing.XUnit.v3/tests.lib.project.xunit.v3.common.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing.XUnit.v3/tests.lib.project.xunit.v3.common.props new file mode 100644 index 0000000..962eec1 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing.XUnit.v3/tests.lib.project.xunit.v3.common.props @@ -0,0 +1,5 @@ + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing.XUnit.v3/tests.project.xunit.v3.common.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing.XUnit.v3/tests.project.xunit.v3.common.props new file mode 100644 index 0000000..7fa68e2 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing.XUnit.v3/tests.project.xunit.v3.common.props @@ -0,0 +1,8 @@ + + + + + true + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing.XUnit.v3/tests.project.xunit.v3.routine.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing.XUnit.v3/tests.project.xunit.v3.routine.props new file mode 100644 index 0000000..08b3e1f --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing.XUnit.v3/tests.project.xunit.v3.routine.props @@ -0,0 +1,8 @@ + + + + + true + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing.XUnit.v3/tests.project.xunit.v3.routine.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing.XUnit.v3/tests.project.xunit.v3.routine.targets new file mode 100644 index 0000000..095e1fc --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing.XUnit.v3/tests.project.xunit.v3.routine.targets @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + Exe + xunit.runner.json + + + + + + + + + + +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json" +} + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/audit/audit.internalsVisibleTo.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/audit/audit.internalsVisibleTo.targets new file mode 100644 index 0000000..8de8fe0 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/audit/audit.internalsVisibleTo.targets @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/audit/audit.tests.lib.project.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/audit/audit.tests.lib.project.targets new file mode 100644 index 0000000..11cb07e --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/audit/audit.tests.lib.project.targets @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/audit/audit.tests.project.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/audit/audit.tests.project.targets new file mode 100644 index 0000000..8075de2 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/audit/audit.tests.project.targets @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + <_MSKit_CsprojText013>$([System.IO.File]::ReadAllText('$(MSBuildProjectFullPath)')) + <_MSKit_CsprojText013>$([System.Text.RegularExpressions.Regex]::Replace($(_MSKit_CsprojText013), '<!--[\s\S]*?-->', '')) + <_MSKit_HasDirectIsTestsProject>$([System.Text.RegularExpressions.Regex]::IsMatch($(_MSKit_CsprojText013), '<IsTestsProject\s*>')) + + + + + + + + + + + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/implicit.package.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/implicit.package.targets new file mode 100644 index 0000000..68fa036 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/implicit.package.targets @@ -0,0 +1,42 @@ + + + + + [7.2.2] + 4.3.0 + 9.6.0 + 6.2.0 + 1.0.17 + 18.10.1 + 2.4.1 + 18.11.2 + + + + + + + + + + + + + + + + $(MSKit_PackageVersion_FluentAssertions) + $(MSKit_PackageVersion_Shouldly) + $(MSKit_PackageVersion_AwesomeAssertions) + $(MSKit_PackageVersion_NSubstitute) + $(MSKit_PackageVersion_NSubstituteAnalyzersCSharp) + $(MSKit_PackageVersion_MicrosoftNetTestSdk) + $(MSKit_PackageVersion_MicrosoftTestingExtensionsTrxReport) + $(MSKit_PackageVersion_MicrosoftTestingExtensionsCodeCoverage) + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/init.last.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/init.last.targets new file mode 100644 index 0000000..ea85fe1 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/init.last.targets @@ -0,0 +1,6 @@ + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/init.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/init.props new file mode 100644 index 0000000..73ea576 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/init.props @@ -0,0 +1,15 @@ + + + + + $(MSBuildThisFileDirectory)tests.project.common.props + $(MSBuildThisFileDirectory)tests.lib.project.common.props + AwesomeAssertions + NSubstitute + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/init.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/init.targets new file mode 100644 index 0000000..958c8f9 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/init.targets @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/test.project.internalsVisibleTo.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/test.project.internalsVisibleTo.targets new file mode 100644 index 0000000..d651dd7 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/test.project.internalsVisibleTo.targets @@ -0,0 +1,31 @@ + + + + + $(MSKit_SlnFileDirectory) + $(MSKit_GitRoot)/ + + + $(MSKit_TestsDir)/ + + + + <_AllCsprojsUnderTestsDir Include="$(MSKit_TestsDir)**/*.csproj" Exclude="$(MSKit_TestsDir)**/bin/**;$(MSKit_TestsDir)**/obj/**;$(MSKit_TestsDir)**/node_modules/**" /> + + + + + + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/tests.lib.project.common.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/tests.lib.project.common.props new file mode 100644 index 0000000..9685d6b --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/tests.lib.project.common.props @@ -0,0 +1,21 @@ + + + + + <_MSKit_TestingFrameworkAtLibCommonPropsImport>$(MSKit_TestingFramework) + <_MSKit_TestsLibProjectCommonPropsImported>True + True + + + + + + $(_MSKit_TestingFramework_xunit_v3_LibCommonPropsPath) + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/tests.project.common.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/tests.project.common.props new file mode 100644 index 0000000..72b358c --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/tests.project.common.props @@ -0,0 +1,22 @@ + + + + + <_MSKit_TestingFrameworkAtCommonPropsImport>$(MSKit_TestingFramework) + <_MSKit_TestsProjectCommonPropsImported>True + True + True + + + + + + $(_MSKit_TestingFramework_xunit_v3_CommonPropsPath) + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/tests.project.lib.global.usings.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/tests.project.lib.global.usings.targets new file mode 100644 index 0000000..3c3053d --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/tests.project.lib.global.usings.targets @@ -0,0 +1,28 @@ + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/tests.project.mtp.routine.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/tests.project.mtp.routine.targets new file mode 100644 index 0000000..253fb90 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/tests.project.mtp.routine.targets @@ -0,0 +1,25 @@ + + + + testconfig.json + + + + + + + + + + +{ + "diagnosticMessages": false +} + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/tests.project.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/tests.project.props new file mode 100644 index 0000000..fce3b86 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/tests.project.props @@ -0,0 +1,44 @@ + + + + + + \.(Tests(\.Integration|\.Unit)?|IntegrationTests|UnitTests)$ + $([System.Text.RegularExpressions.Regex]::IsMatch($(MSBuildProjectName), $(MSKit_TestsProjectNameRegex))) + True + + + \.(TestsSuite|TestsFixtures|Fixtures)$ + $([System.Text.RegularExpressions.Regex]::IsMatch($(MSBuildProjectName), $(MSKit_TestsLibProjectNameRegex))) + True + + <_MSKit_TestsProjectNameRegex_Shared>$(MSKit_TestsProjectNameRegex) + <_MSKit_TestsLibProjectNameRegex_Shared>$(MSKit_TestsLibProjectNameRegex) + + + + + + + + + + Exe + True + + --ignore-exit-code 8 + + + + <_MSKit_TestsProjectPropsImported>True + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/tests.project.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/tests.project.targets new file mode 100644 index 0000000..1bd4bba --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Testing/tests.project.targets @@ -0,0 +1,35 @@ + + + + + False + Library + + + + False + False + + + + True + + True + + + + True + + + + True + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.TfmConstants/init.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.TfmConstants/init.props new file mode 100644 index 0000000..0f2d9a1 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.TfmConstants/init.props @@ -0,0 +1,5 @@ + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.TfmConstants/init.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.TfmConstants/init.targets new file mode 100644 index 0000000..6c58f27 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.TfmConstants/init.targets @@ -0,0 +1,5 @@ + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.TfmConstants/tfm.constants.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.TfmConstants/tfm.constants.props new file mode 100644 index 0000000..fca8585 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.TfmConstants/tfm.constants.props @@ -0,0 +1,44 @@ + + + + true + + True + + True + True + True + + True + + True + True + True + True + True + True + True + True + + True + True + True + True + True + True + True + True + + + + 7 + 8 + 9 + 10 + 11 + 12 + 13 + 14 + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.TfmConstants/tfm.constants.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.TfmConstants/tfm.constants.targets new file mode 100644 index 0000000..d68b6f9 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.TfmConstants/tfm.constants.targets @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + True + + True + True + True + + True + + True + True + True + True + True + True + True + True + + True + True + True + True + True + True + True + True + + + + 7 + 8 + 9 + 10 + 11 + 12 + 13 + 14 + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Vcs.GitHub/ci.github.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Vcs.GitHub/ci.github.props new file mode 100644 index 0000000..30decc3 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Vcs.GitHub/ci.github.props @@ -0,0 +1,27 @@ + + + + + True + False + + $(GITHUB_RUN_ID) + + $(GITHUB_RUN_NUMBER) + $(GITHUB_SHA) + + True + $(GITHUB_REF_NAME) + $([System.Text.RegularExpressions.Regex]::Replace('$(MSKit_ReleaseTagRaw)', '^[vV]', '')) + + $([System.Text.RegularExpressions.Regex]::Match('$(GITHUB_REF)', 'refs/pull/(\d+)/').Groups[1].Value) + $([System.Text.RegularExpressions.Regex]::Match('$(GITHUB_REF_NAME)', '^\d+').Value) + + $(GITHUB_SERVER_URL)/$(GITHUB_REPOSITORY) + https://github.com/$(GITHUB_REPOSITORY) + + GitHub + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Vcs.GitHub/init.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Vcs.GitHub/init.props new file mode 100644 index 0000000..48bdbb4 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit.Vcs.GitHub/init.props @@ -0,0 +1,5 @@ + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/audit/audit.implicit.packages.duplicates.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/audit/audit.implicit.packages.duplicates.targets new file mode 100644 index 0000000..b03a750 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/audit/audit.implicit.packages.duplicates.targets @@ -0,0 +1,34 @@ + + + + + + False + + + + + <_MSKit_ConsumerIdentities Include="%(PackageVersion.Identity)" + Condition="'%(PackageVersion.ControlledBy)'==''" /> + + + <_MSKit_ConsumerIdsStr>;@(_MSKit_ConsumerIdentities, ';'); + + + <_MSKit_DuplicateReport Include="@(PackageVersion)" + Condition="'%(PackageVersion.ControlledBy)'!='' + AND $(_MSKit_ConsumerIdsStr.Contains(';%(PackageVersion.Identity);'))" /> + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/audit/audit.prerelease.packages.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/audit/audit.prerelease.packages.targets new file mode 100644 index 0000000..534e94f --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/audit/audit.prerelease.packages.targets @@ -0,0 +1,29 @@ + + + + + + true + + + + + <_MSKit_PackagesWithVersion Include="@(PackageReference)" Condition="'%(PackageReference.Version)' != ''" /> + <_MSKit_PackagesWithVersion Include="@(PackageVersion)" Condition="'%(PackageVersion.Version)' != ''" /> + <_MSKit_PrereleaseReferences Include="@(_MSKit_PackagesWithVersion)" + Condition="$([System.String]::Copy('%(_MSKit_PackagesWithVersion.Identity)').StartsWith('$(MSKit_PrereleasePackagePrefix)')) AND $([System.String]::Copy('%(_MSKit_PackagesWithVersion.Version)').Contains('-'))" /> + + + <_MSKit_PrereleaseText>Prerelease $(MSKit_PrereleasePackagePrefix)* packages referenced on stable branch '$(MSKit_Branch)':%0A@(_MSKit_PrereleaseReferences->' %(Identity) %(Version)', '%0A')%0AFix: replace them with stable versions in Directory.Packages.props or the csproj. + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/audit/audit.restrict.packages.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/audit/audit.restrict.packages.targets new file mode 100644 index 0000000..4fba85c --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/audit/audit.restrict.packages.targets @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/audit/audit.restrict.reference.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/audit/audit.restrict.reference.targets new file mode 100644 index 0000000..6e84fa3 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/audit/audit.restrict.reference.targets @@ -0,0 +1,35 @@ + + + + + + False + $(MSKit_RestrictReferences) + $(MSKit_RestrictReferences) + + + + + + + + + + + + <_MSKit_PackageReferenceNotAllowListed Include="@(PackageReference)" + Condition=" '%(PackageReference.Allowed)'!='True' AND '@(PackageReference)'!='' AND '%(Identity)'!=''" /> + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/audit/audit.shared.properties.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/audit/audit.shared.properties.targets new file mode 100644 index 0000000..eeda662 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/audit/audit.shared.properties.targets @@ -0,0 +1,98 @@ + + + + + + + <_MSKit_GuardXmlPeek Condition="'$(MSKit_GuardXmlPeekAudit)'!='False'">True + <_MSKit_SPC_CsprojText Condition="'$(_MSKit_GuardXmlPeek)'=='True'">$([System.IO.File]::ReadAllText('$(MSBuildProjectFullPath)')) + <_MSKit_HasTargetFramework Condition="'$(_MSKit_GuardXmlPeek)'!='True'">True + <_MSKit_HasTargetFramework Condition="'$(_MSKit_GuardXmlPeek)'=='True' AND ($(_MSKit_SPC_CsprojText.Contains('<TargetFramework>')) OR $(_MSKit_SPC_CsprojText.Contains('<TargetFramework ')))">True + <_MSKit_HasTargetFrameworks Condition="'$(_MSKit_GuardXmlPeek)'!='True'">True + <_MSKit_HasTargetFrameworks Condition="'$(_MSKit_GuardXmlPeek)'=='True' AND ($(_MSKit_SPC_CsprojText.Contains('<TargetFrameworks>')) OR $(_MSKit_SPC_CsprojText.Contains('<TargetFrameworks ')))">True + + + + + + + + + + <_MSKit_TF_ProjNodeCount>@(_MSKit_TF_NodeCheckXmlResult->Count()) + <_MSKit_TFs_ProjNodeCount>@(_MSKit_TFs_NodeCheckXmlResult->Count()) + + + + + + + + + + + + + + + + + + + + True + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/audit/audit.version.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/audit/audit.version.targets new file mode 100644 index 0000000..aaea8ee --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/audit/audit.version.targets @@ -0,0 +1,93 @@ + + + + + + + + <_MSKit_GuardXmlPeek Condition="'$(MSKit_GuardXmlPeekAudit)'!='False'">True + <_MSKit_RMV_CsprojText Condition="'$(_MSKit_GuardXmlPeek)'=='True'">$([System.IO.File]::ReadAllText('$(MSBuildProjectFullPath)')) + <_MSKit_HasVersion Condition="'$(_MSKit_GuardXmlPeek)'!='True'">True + <_MSKit_HasVersion Condition="'$(_MSKit_GuardXmlPeek)'=='True' AND ($(_MSKit_RMV_CsprojText.Contains('<Version>')) OR $(_MSKit_RMV_CsprojText.Contains('<Version ')))">True + + + + + + + + + + + + + + <_MSKit_ValidPlaceholders>prefix;buildNumber;pipelineId;branchSlug;branchTicket;branchAspectSuffix;buildDateUtcDash;buildDateUtcDot;versionTag;releaseTag;prNumber;commitShaShort + <_MSKit_PlaceholderHelp> +Valid placeholders: + {prefix} - $(VersionPrefix), declared in Directory.Version.props + {buildNumber} - CI run number (GITHUB_RUN_NUMBER); DateBased caps it at 65535 + {pipelineId} - CI run id (GITHUB_RUN_ID) + {releaseTag} - the release tag with a leading v removed (tag builds only) + {prNumber} - the pull request number (pull request builds only) + {branchSlug} - the branch name with every character outside [0-9A-Za-z-] replaced by '-' + {branchTicket} - the first [A-Za-z]+-[0-9]+ match in {branchSlug}, else {branchSlug} + {branchAspectSuffix} - the part of {branchSlug} from its first '--' to the end, else empty + {buildDateUtcDash} - UTC build time as yyyyMMdd-HHmmss + {buildDateUtcDot} - UTC build time as yyyy.M.d + {versionTag} - $(VersionTag) + {commitShaShort} - the first 8 characters of the commit SHA + + <_MSKit_AllTemplates>$(MSKit_VersionTemplate)|$(MSKit_StableVersionTemplate)|$(MSKit_ReleaseVersionTemplate)|$(MSKit_PullRequestVersionTemplate) + <_MSKit_TplHasTokens>false + <_MSKit_TplHasTokens Condition="$([System.Text.RegularExpressions.Regex]::IsMatch('$(_MSKit_AllTemplates)', '\{[A-Za-z][A-Za-z0-9]*\}'))">true + <_MSKit_TplNames Condition="'$(_MSKit_TplHasTokens)'=='true'">$([System.Text.RegularExpressions.Regex]::Replace('$(_MSKit_AllTemplates)', '[^{}]*\{([A-Za-z][A-Za-z0-9]*)\}[^{}]*', '$1;').TrimEnd(';')) + + + + <_MSKit_ValidPlaceholder Include="$(_MSKit_ValidPlaceholders.Split(';'))" /> + <_MSKit_TplNameItem Include="$(_MSKit_TplNames.Split(';'))" Condition="'$(_MSKit_TplNames)'!=''" /> + <_MSKit_UnknownPlaceholder Include="@(_MSKit_TplNameItem)" Exclude="@(_MSKit_ValidPlaceholder)" /> + + + + + + + + + + + + + + + <_MSKit_ReleaseTagCore>$([System.Text.RegularExpressions.Regex]::Match('$(MSKit_ReleaseTag)', '^\d+\.\d+\.\d+').Value) + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/init.last.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/init.last.props new file mode 100644 index 0000000..5787c55 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/init.last.props @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/init.last.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/init.last.targets new file mode 100644 index 0000000..d4f4b5e --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/init.last.targets @@ -0,0 +1,7 @@ + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/init.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/init.props new file mode 100644 index 0000000..ef44584 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/init.props @@ -0,0 +1,17 @@ + + + + enable + enable + latest + True + False + True + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/init.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/init.targets new file mode 100644 index 0000000..8f6c1b4 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/init.targets @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/product.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/product.props new file mode 100644 index 0000000..85cd674 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/product.props @@ -0,0 +1,13 @@ + + + + $(MSBuildProjectName) + $([System.DateTime]::UtcNow.Year) + $(ManufacturerName) + $(FullManufacturerName) + + Copyright (c) $(Year) $(FullManufacturerName) + $(ManufacturerName) + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/project.global.metadata.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/project.global.metadata.targets new file mode 100644 index 0000000..9b34627 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/project.global.metadata.targets @@ -0,0 +1,31 @@ + + + + + + all + all + + + all + all + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/project.global.usings.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/project.global.usings.targets new file mode 100644 index 0000000..8e42751 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/project.global.usings.targets @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/twae.shared.init.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/twae.shared.init.props new file mode 100644 index 0000000..ffddb18 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/twae.shared.init.props @@ -0,0 +1,8 @@ + + + + + $(TreatWarningsAsErrors) + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/version.props b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/version.props new file mode 100644 index 0000000..6e356cf --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/version.props @@ -0,0 +1,145 @@ + + + + + + $(VersionPrefix) + + SemVer + DevEnv + + 0 + 0 + + ^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ + false + true + + + + + $([System.Environment]::GetEnvironmentVariable('MSKit_BuildDateTimeUtc')) + + + <_MSKit_Ph_buildDateUtcDash>$([System.DateTime]::Parse('$(MSKit_BuildDateTimeUtc)').ToUniversalTime().ToString(yyyyMMdd-HHmmss)) + <_MSKit_Ph_buildDateUtcDot>$([System.DateTime]::Parse('$(MSKit_BuildDateTimeUtc)').ToUniversalTime().ToString(yyyy.M.d)) + + + <_MSKit_Ph_buildDateUtcDash>$([System.DateTime]::UtcNow.ToString(yyyyMMdd-HHmmss)) + <_MSKit_Ph_buildDateUtcDot>$([System.DateTime]::UtcNow.ToString(yyyy.M.d)) + + + + + + 0.0.0 + {releaseTag} + {prefix}-pr.{prNumber}.{buildNumber} + {prefix}-ci.{buildNumber} + {prefix}-ci.{buildNumber} + + + + + 1.0.0 + {prefix}-{buildDateUtcDash}-{branchTicket}{branchAspectSuffix} + {prefix} + + + + + 1.0.0 + {prefix}.{buildNumber}-{buildDateUtcDash}-{branchTicket}{branchAspectSuffix} + {prefix}.{buildNumber} + + + + + $(_MSKit_Ph_buildDateUtcDot) + $([MSBuild]::Modulo($(MSKit_CIPipelineId), 65535)) + 1 + {buildDateUtcDot}.{buildNumber}-{branchTicket}{branchAspectSuffix} + {buildDateUtcDot}.{buildNumber} + + + + + {versionTag} + {versionTag} + + + + + 9999.0.0 + + + 9999.0.0 + 9999.0.0 + + + + + + + + + + + + <_MSKit_Ph_prefix>$(VersionPrefix) + <_MSKit_Ph_versionTag>$(VersionTag) + <_MSKit_Ph_releaseTag Condition="'$(MSKit_IsReleaseTagValid)'=='true'">$(MSKit_ReleaseTag) + + <_MSKit_Ph_releaseTag Condition="'$(MSKit_IsReleaseTagValid)'!='true'">0.0.0-invalid-release-tag + <_MSKit_Ph_prNumber>$(MSKit_PullRequestNumber) + <_MSKit_Ph_buildNumber>$(MSKit_BuildNumber) + <_MSKit_Ph_pipelineId>$(MSKit_CIPipelineId) + <_MSKit_Ph_branchSlug>$([System.Text.RegularExpressions.Regex]::Replace('$(MSKit_Branch)', '[^0-9A-Za-z-]', '-')) + + <_MSKit_BranchTicketMatch>$([System.Text.RegularExpressions.Regex]::Match($(_MSKit_Ph_branchSlug), '[A-Za-z]+-\d+').Value) + <_MSKit_Ph_branchTicket Condition="'$(_MSKit_BranchTicketMatch)'!=''">$(_MSKit_BranchTicketMatch) + <_MSKit_Ph_branchTicket Condition="'$(_MSKit_BranchTicketMatch)'==''">$(_MSKit_Ph_branchSlug) + + <_MSKit_BranchAspectIdx>$(_MSKit_Ph_branchSlug.IndexOf('--')) + <_MSKit_Ph_branchAspectSuffix Condition="'$(_MSKit_BranchAspectIdx)'!='-1'">$(_MSKit_Ph_branchSlug.Substring($(_MSKit_BranchAspectIdx))) + + <_MSKit_Ph_commitShaShort Condition="$(MSKit_CommitSha.Length) >= 8">$(MSKit_CommitSha.Substring(0, 8)) + + + + <_MSKit_EffectiveTemplate Condition="'$(MSKit_IsStableBranch)'=='true'">$(MSKit_StableVersionTemplate) + <_MSKit_EffectiveTemplate Condition="'$(MSKit_IsStableBranch)'!='true'">$(MSKit_VersionTemplate) + <_MSKit_EffectiveTemplate Condition="'$(MSKit_PullRequestNumber)'!='' AND '$(MSKit_PullRequestVersionTemplate)'!=''">$(MSKit_PullRequestVersionTemplate) + <_MSKit_EffectiveTemplate Condition="'$(MSKit_IsReleaseTag)'=='True' AND '$(MSKit_ReleaseVersionTemplate)'!=''">$(MSKit_ReleaseVersionTemplate) + + <_MSKit_Rendered>$(_MSKit_EffectiveTemplate) + <_MSKit_Rendered>$(_MSKit_Rendered.Replace('{prefix}', '$(_MSKit_Ph_prefix)')) + <_MSKit_Rendered>$(_MSKit_Rendered.Replace('{buildNumber}', '$(_MSKit_Ph_buildNumber)')) + <_MSKit_Rendered>$(_MSKit_Rendered.Replace('{pipelineId}', '$(_MSKit_Ph_pipelineId)')) + <_MSKit_Rendered>$(_MSKit_Rendered.Replace('{branchSlug}', '$(_MSKit_Ph_branchSlug)')) + <_MSKit_Rendered>$(_MSKit_Rendered.Replace('{branchTicket}', '$(_MSKit_Ph_branchTicket)')) + <_MSKit_Rendered>$(_MSKit_Rendered.Replace('{branchAspectSuffix}', '$(_MSKit_Ph_branchAspectSuffix)')) + <_MSKit_Rendered>$(_MSKit_Rendered.Replace('{buildDateUtcDash}', '$(_MSKit_Ph_buildDateUtcDash)')) + <_MSKit_Rendered>$(_MSKit_Rendered.Replace('{buildDateUtcDot}', '$(_MSKit_Ph_buildDateUtcDot)')) + <_MSKit_Rendered>$(_MSKit_Rendered.Replace('{versionTag}', '$(_MSKit_Ph_versionTag)')) + <_MSKit_Rendered>$(_MSKit_Rendered.Replace('{releaseTag}', '$(_MSKit_Ph_releaseTag)')) + <_MSKit_Rendered>$(_MSKit_Rendered.Replace('{prNumber}', '$(_MSKit_Ph_prNumber)')) + <_MSKit_Rendered>$(_MSKit_Rendered.Replace('{commitShaShort}', '$(_MSKit_Ph_commitShaShort)')) + + <_MSKit_RenderHasUnknown>false + <_MSKit_RenderHasUnknown Condition="$([System.Text.RegularExpressions.Regex]::IsMatch('$(_MSKit_Rendered)', '\{[A-Za-z][A-Za-z0-9]*\}'))">true + + $(_MSKit_Rendered) + + unknown placeholder: $(_MSKit_Rendered) + + + diff --git a/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/version.targets b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/version.targets new file mode 100644 index 0000000..8b72fe8 --- /dev/null +++ b/manager/.toolkit/msbuild/DragoAnt.MSBuildKit/version.targets @@ -0,0 +1,38 @@ + + + + + + + <_MSKit_Ph_prefix>$(VersionPrefix) + + <_MSKit_EffectiveTemplate Condition="'$(MSKit_IsStableBranch)'=='true'">$(MSKit_StableVersionTemplate) + <_MSKit_EffectiveTemplate Condition="'$(MSKit_IsStableBranch)'!='true'">$(MSKit_VersionTemplate) + <_MSKit_EffectiveTemplate Condition="'$(MSKit_PullRequestNumber)'!='' AND '$(MSKit_PullRequestVersionTemplate)'!=''">$(MSKit_PullRequestVersionTemplate) + <_MSKit_EffectiveTemplate Condition="'$(MSKit_IsReleaseTag)'=='True' AND '$(MSKit_ReleaseVersionTemplate)'!=''">$(MSKit_ReleaseVersionTemplate) + + <_MSKit_Rendered>$(_MSKit_EffectiveTemplate) + <_MSKit_Rendered>$(_MSKit_Rendered.Replace('{prefix}', '$(_MSKit_Ph_prefix)')) + <_MSKit_Rendered>$(_MSKit_Rendered.Replace('{buildNumber}', '$(_MSKit_Ph_buildNumber)')) + <_MSKit_Rendered>$(_MSKit_Rendered.Replace('{pipelineId}', '$(_MSKit_Ph_pipelineId)')) + <_MSKit_Rendered>$(_MSKit_Rendered.Replace('{branchSlug}', '$(_MSKit_Ph_branchSlug)')) + <_MSKit_Rendered>$(_MSKit_Rendered.Replace('{branchTicket}', '$(_MSKit_Ph_branchTicket)')) + <_MSKit_Rendered>$(_MSKit_Rendered.Replace('{branchAspectSuffix}', '$(_MSKit_Ph_branchAspectSuffix)')) + <_MSKit_Rendered>$(_MSKit_Rendered.Replace('{buildDateUtcDash}', '$(_MSKit_Ph_buildDateUtcDash)')) + <_MSKit_Rendered>$(_MSKit_Rendered.Replace('{buildDateUtcDot}', '$(_MSKit_Ph_buildDateUtcDot)')) + <_MSKit_Rendered>$(_MSKit_Rendered.Replace('{versionTag}', '$(_MSKit_Ph_versionTag)')) + <_MSKit_Rendered>$(_MSKit_Rendered.Replace('{releaseTag}', '$(_MSKit_Ph_releaseTag)')) + <_MSKit_Rendered>$(_MSKit_Rendered.Replace('{prNumber}', '$(_MSKit_Ph_prNumber)')) + <_MSKit_Rendered>$(_MSKit_Rendered.Replace('{commitShaShort}', '$(_MSKit_Ph_commitShaShort)')) + + $(_MSKit_Rendered) + $(_MSKit_Rendered) + + + + + diff --git a/manager/.toolkit/msbuild/init.company.props b/manager/.toolkit/msbuild/init.company.props new file mode 100644 index 0000000..587098c --- /dev/null +++ b/manager/.toolkit/msbuild/init.company.props @@ -0,0 +1,35 @@ + + + + + DragoAnt + DragoAnt + + MIT + $(MSBuildThisFileDirectory)../res/package.icon.png + DragoAnt. + + ^(main|release/.+)$ + ReleaseTag + + + True + + True + + CS1591;xUnit1051;$(NoWarn) + + $(WarningsNotAsErrors);NU1901;NU1902;NU1903;NU1904 + + xunit.v3 + AwesomeAssertions + + + + + + + diff --git a/manager/.toolkit/msbuild/init.company.targets b/manager/.toolkit/msbuild/init.company.targets new file mode 100644 index 0000000..45bff74 --- /dev/null +++ b/manager/.toolkit/msbuild/init.company.targets @@ -0,0 +1,5 @@ + + + + + diff --git a/manager/.toolkit/msbuild/init.props b/manager/.toolkit/msbuild/init.props new file mode 100644 index 0000000..580345c --- /dev/null +++ b/manager/.toolkit/msbuild/init.props @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/manager/.toolkit/msbuild/init.targets b/manager/.toolkit/msbuild/init.targets new file mode 100644 index 0000000..f4e11ab --- /dev/null +++ b/manager/.toolkit/msbuild/init.targets @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/manager/.toolkit/res/package.icon.png b/manager/.toolkit/res/package.icon.png new file mode 100644 index 0000000..e890a29 Binary files /dev/null and b/manager/.toolkit/res/package.icon.png differ diff --git a/manager/.toolkit/update.ps1 b/manager/.toolkit/update.ps1 new file mode 100644 index 0000000..d5fdfea --- /dev/null +++ b/manager/.toolkit/update.ps1 @@ -0,0 +1,214 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS +Installs or updates DragoAnt.MSBuildKit in a repository's .toolkit/ folder. + +.DESCRIPTION +Downloads a release zip of the kit from GitHub, checks its SHA-256 against the published +checksum (and against .toolkit/kit.json when the version is unchanged), and rewrites +.toolkit/msbuild/ with the default parts plus the optional parts you selected. + +.EXAMPLE +pwsh .toolkit/update.ps1 # update to the version in kit.json, else the latest +pwsh .toolkit/update.ps1 -Version 0.2.0 -DryRun # show what 0.2.0 would change +pwsh .toolkit/update.ps1 -Add PackageAsProj # install an optional part +#> +[CmdletBinding()] +param( + # Kit version to install; default: the version in .toolkit/kit.json, else the latest release. + [string] $Version, + # Optional parts to install, e.g. PackageAsProj, EF, Project.SourceGenerator. + [string[]] $Add = @(), + # Optional parts to uninstall. + [string[]] $Remove = @(), + # Show what would change and change nothing. + [switch] $DryRun, + # Install from a local kit build (a folder containing .toolkit/ or a release zip). + [string] $Source, + # Expected SHA-256 of the release zip (or of a -Source zip). + [string] $Sha256, + # GitHub repository to download releases from. + [string] $Repo = 'DragoAnt/MSBuildKit', + # Repository root; default: the current folder. + [string] $Root +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Say([string] $text) { Write-Host "update: $text" } +function Fail([string] $text) { throw "update: error: $text" } + +if (-not $Root) { $Root = (Get-Location).Path } +$Root = (Resolve-Path $Root).Path +$toolkit = Join-Path $Root '.toolkit' +$kitJsonPath = Join-Path $toolkit 'kit.json' +$kitJson = if (Test-Path $kitJsonPath) { Get-Content -Raw $kitJsonPath | ConvertFrom-Json } else { $null } +function KitJsonValue([string] $name) { + if ($null -ne $kitJson -and $kitJson.PSObject.Properties.Name -contains $name) { return $kitJson.$name } + return $null +} + +if ($Version) { $Version = $Version -replace '^[vV]', '' } +if ($Sha256) { $Sha256 = $Sha256.ToLowerInvariant() } +$recordedRepo = KitJsonValue 'repository' +if ($recordedRepo -and $Repo -eq 'DragoAnt/MSBuildKit') { $Repo = $recordedRepo } + +$work = Join-Path ([System.IO.Path]::GetTempPath()) ("msbuildkit-" + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $work | Out-Null +try { + $actualSha = '' + $kitDir = $null + if ($Source) { + if (Test-Path $Source -PathType Container) { + if (-not (Test-Path (Join-Path $Source '.toolkit/msbuild/init.props'))) { Fail "'$Source' is not a kit build: no .toolkit/msbuild/init.props" } + $kitDir = (Resolve-Path $Source).Path + } + elseif (Test-Path $Source -PathType Leaf) { + $actualSha = (Get-FileHash -Algorithm SHA256 $Source).Hash.ToLowerInvariant() + if ($Sha256 -and $actualSha -ne $Sha256) { Fail "SHA-256 mismatch for '$Source': expected $Sha256, got $actualSha" } + $kitDir = Join-Path $work 'kit' + Expand-Archive -Path $Source -DestinationPath $kitDir + } + else { Fail "-Source '$Source' does not exist" } + if (-not $Version) { + $versionFile = Join-Path $kitDir '.toolkit/kit.version' + $Version = if (Test-Path $versionFile) { (Get-Content $versionFile -TotalCount 1).Trim() } else { '0.0.0-local' } + } + } + else { + if (-not $Version) { $Version = KitJsonValue 'version' } + if (-not $Version) { + $latest = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/latest" -Headers @{ 'User-Agent' = 'msbuildkit-update' } + $Version = ($latest.tag_name -replace '^[vV]', '') + if (-not $Version) { Fail "no release found in $Repo" } + } + $zipName = "msbuildkit-$Version.zip" + $base = "https://github.com/$Repo/releases/download/v$Version" + $zipPath = Join-Path $work $zipName + Say "downloading $base/$zipName" + Invoke-WebRequest -Uri "$base/$zipName" -OutFile $zipPath + Invoke-WebRequest -Uri "$base/$zipName.sha256" -OutFile "$zipPath.sha256" + $published = ((Get-Content "$zipPath.sha256" -TotalCount 1) -split '\s+')[0].ToLowerInvariant() + $actualSha = (Get-FileHash -Algorithm SHA256 $zipPath).Hash.ToLowerInvariant() + if ($actualSha -ne $published) { Fail "SHA-256 mismatch for ${zipName}: published $published, downloaded $actualSha" } + $pinned = KitJsonValue 'sha256' + if (-not $Sha256 -and (KitJsonValue 'version') -eq $Version -and $pinned) { $Sha256 = $pinned.ToLowerInvariant() } + if ($Sha256 -and $actualSha -ne $Sha256) { Fail "SHA-256 mismatch for ${zipName}: expected $Sha256 (kit.json or -Sha256), got $actualSha" } + $kitDir = Join-Path $work 'kit' + Expand-Archive -Path $zipPath -DestinationPath $kitDir + } + + $newToolkit = Join-Path $kitDir '.toolkit' + if (-not (Test-Path (Join-Path $newToolkit 'msbuild/init.props'))) { Fail 'the kit build has no .toolkit/msbuild/init.props' } + $partsPath = Join-Path $newToolkit 'kit.parts' + if (-not (Test-Path $partsPath)) { Fail 'the kit build has no .toolkit/kit.parts' } + if ((Test-Path $toolkit) -and (Resolve-Path $newToolkit).Path -eq (Resolve-Path $toolkit).Path) { + Fail 'the target .toolkit is the kit source itself; run from the repository you want to update, or pass -Root' + } + $parts = [ordered]@{} + foreach ($line in Get-Content $partsPath) { + $line = $line.Trim() + if (-not $line -or $line.StartsWith('#')) { continue } + $fields = $line -split '\s+' + $parts[$fields[0]] = [pscustomobject]@{ Kind = $fields[1]; Requires = @($fields | Select-Object -Skip 2) } + } + function PartDir([string] $name) { if ($name -eq 'Trunk') { 'DragoAnt.MSBuildKit' } else { "DragoAnt.MSBuildKit.$name" } } + + $optional = [System.Collections.Generic.List[string]]::new() + $recordedParts = @(KitJsonValue 'parts' | Where-Object { $_ }) + foreach ($p in @($recordedParts) + @($Add)) { + if (-not $parts.Contains($p)) { Fail "unknown part '$p'; known parts: $($parts.Keys -join ' ')" } + if ($parts[$p].Kind -eq 'optional' -and -not $optional.Contains($p)) { $optional.Add($p) } + } + foreach ($p in $Remove) { + if (-not $parts.Contains($p)) { Fail "unknown part '$p'" } + if ($parts[$p].Kind -ne 'optional') { Fail "'$p' is a default part and cannot be removed" } + [void]$optional.Remove($p) + } + + $selected = [System.Collections.Generic.List[string]]::new() + foreach ($name in $parts.Keys) { if ($parts[$name].Kind -eq 'default') { $selected.Add($name) } } + $pending = [System.Collections.Generic.Queue[string]]::new([string[]]$optional) + while ($pending.Count -gt 0) { + $p = $pending.Dequeue() + if ($selected.Contains($p)) { continue } + $selected.Add($p) + foreach ($d in $parts[$p].Requires) { $pending.Enqueue($d) } + } + $recorded = @($parts.Keys | Where-Object { $selected.Contains($_) -and $parts[$_].Kind -eq 'optional' }) + + $stage = Join-Path $work 'stage/msbuild' + New-Item -ItemType Directory -Path $stage -Force | Out-Null + Get-ChildItem -File (Join-Path $newToolkit 'msbuild') | Copy-Item -Destination $stage + foreach ($p in $selected) { + $d = PartDir $p + $from = Join-Path $newToolkit "msbuild/$d" + if (-not (Test-Path $from)) { Fail "the kit build has no part folder msbuild/$d" } + Copy-Item -Recurse $from (Join-Path $stage $d) + } + + $oldVersion = KitJsonValue 'version' + Say "kit $Repo $(if ($oldVersion) { $oldVersion } else { 'none' }) -> $Version$(if ($actualSha) { " (sha256 $actualSha)" })" + Say "parts: $($selected -join ' ')" + + $currentMsbuild = Join-Path $toolkit 'msbuild' + function RelativeFiles([string] $dir) { + if (-not (Test-Path $dir)) { return @() } + $full = (Resolve-Path $dir).Path + @(Get-ChildItem -Recurse -File $full | ForEach-Object { $_.FullName.Substring($full.Length + 1).Replace('\', '/') } | Sort-Object) + } + $old = RelativeFiles $currentMsbuild + $new = RelativeFiles $stage + $changes = 0 + foreach ($f in $new) { + if ($old -notcontains $f) { Say " add .toolkit/msbuild/$f"; $changes++ } + elseif ((Get-FileHash (Join-Path $currentMsbuild $f)).Hash -ne (Get-FileHash (Join-Path $stage $f)).Hash) { Say " change .toolkit/msbuild/$f"; $changes++ } + } + foreach ($f in $old) { if ($new -notcontains $f) { Say " remove .toolkit/msbuild/$f"; $changes++ } } + + $dbp = Join-Path $Root 'Directory.Build.props' + $dbt = Join-Path $Root 'Directory.Build.targets' + if ($DryRun) { + Say "$changes file(s) under .toolkit/msbuild would change" + if (-not (Test-Path $dbp)) { Say ' would create Directory.Build.props' } + if (-not (Test-Path $dbt)) { Say ' would create Directory.Build.targets' } + Say 'dry run: nothing changed' + return + } + + New-Item -ItemType Directory -Path $toolkit -Force | Out-Null + if (Test-Path $currentMsbuild) { Remove-Item -Recurse -Force $currentMsbuild } + Copy-Item -Recurse $stage $currentMsbuild + $res = Join-Path $newToolkit 'res' + if (Test-Path $res) { New-Item -ItemType Directory -Path (Join-Path $toolkit 'res') -Force | Out-Null; Copy-Item -Recurse -Force (Join-Path $res '*') (Join-Path $toolkit 'res') } + foreach ($f in 'update.sh', 'update.ps1', 'kit.parts') { + $from = Join-Path $newToolkit $f + if (Test-Path $from) { Copy-Item -Force $from (Join-Path $toolkit $f) } + } + Remove-Item -Force -ErrorAction SilentlyContinue (Join-Path $toolkit 'kit.version') + + $partsJson = ($recorded | ForEach-Object { "`"$_`"" }) -join ', ' + $json = "{`n `"repository`": `"$Repo`",`n `"version`": `"$Version`",`n `"sha256`": `"$actualSha`",`n `"parts`": [$partsJson]`n}`n" + [System.IO.File]::WriteAllText($kitJsonPath, $json) + Say "$changes file(s) under .toolkit/msbuild changed" + + if (-not (Test-Path $dbp)) { + [System.IO.File]::WriteAllText($dbp, "`n`n `n`n`n") + Say 'created Directory.Build.props' + } + elseif ((Get-Content -Raw $dbp) -notmatch '\.toolkit[/\\]msbuild[/\\]init\.props') { + Say 'add to Directory.Build.props: ' + } + if (-not (Test-Path $dbt)) { + [System.IO.File]::WriteAllText($dbt, "`n`n `n`n`n") + Say 'created Directory.Build.targets' + } + elseif ((Get-Content -Raw $dbt) -notmatch '\.toolkit[/\\]msbuild[/\\]init\.targets') { + Say 'add to Directory.Build.targets: ' + } + Say "done: DragoAnt.MSBuildKit $Version installed in $toolkit" +} +finally { + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $work +} diff --git a/manager/.toolkit/update.sh b/manager/.toolkit/update.sh new file mode 100644 index 0000000..e1cc11c --- /dev/null +++ b/manager/.toolkit/update.sh @@ -0,0 +1,228 @@ +#!/bin/sh +# Installs or updates DragoAnt.MSBuildKit in a repository's .toolkit/ folder. +# Usage: sh .toolkit/update.sh [--version X.Y.Z] [--add PART] [--remove PART] [--dry-run] +# [--source DIR|ZIP] [--sha256 HEX] [--repo OWNER/NAME] [--root DIR] + +# One brace group, parsed in full before it runs: the update may replace this file while it runs. +{ +set -eu + +repo="DragoAnt/MSBuildKit" +version="" +source="" +expected_sha="" +root="" +dry_run=0 +add_parts="" +remove_parts="" + +usage() { + sed -n '2,4p' "$0" | sed 's/^# \{0,1\}//' + cat <<'EOF' + + --version X.Y.Z kit version to install (default: the version in .toolkit/kit.json, else the latest release) + --add PART install an optional part (repeatable), e.g. --add PackageAsProj + --remove PART uninstall an optional part (repeatable) + --dry-run show what would change and change nothing + --source DIR|ZIP install from a local kit build instead of a GitHub release + --sha256 HEX expected SHA-256 of the release zip (or of a --source zip) + --repo OWNER/NAME GitHub repository to download releases from (default: DragoAnt/MSBuildKit) + --root DIR repository root (default: the current folder) +EOF +} + +fail() { echo "update: error: $*" >&2; exit 1; } +say() { echo "update: $*"; } + +while [ $# -gt 0 ]; do + case "$1" in + --version) [ $# -ge 2 ] || fail "--version needs a value"; version="${2#v}"; shift 2 ;; + --source) [ $# -ge 2 ] || fail "--source needs a value"; source="$2"; shift 2 ;; + --sha256) [ $# -ge 2 ] || fail "--sha256 needs a value"; expected_sha=$(printf '%s' "$2" | tr 'A-F' 'a-f'); shift 2 ;; + --repo) [ $# -ge 2 ] || fail "--repo needs a value"; repo="$2"; shift 2 ;; + --root) [ $# -ge 2 ] || fail "--root needs a value"; root="$2"; shift 2 ;; + --add) [ $# -ge 2 ] || fail "--add needs a value"; add_parts="$add_parts $2"; shift 2 ;; + --remove) [ $# -ge 2 ] || fail "--remove needs a value"; remove_parts="$remove_parts $2"; shift 2 ;; + --dry-run) dry_run=1; shift ;; + -h|--help) usage; exit 0 ;; + *) fail "unknown argument '$1' (see --help)" ;; + esac +done + +[ -n "$root" ] || root=$(pwd) +[ -d "$root" ] || fail "root '$root' is not a directory" +toolkit="$root/.toolkit" +kit_json="$toolkit/kit.json" + +json_value() { [ -f "$kit_json" ] && sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$kit_json" | head -n 1 || true; } +json_parts() { [ -f "$kit_json" ] && sed -n 's/.*"parts"[[:space:]]*:[[:space:]]*\[\(.*\)\].*/\1/p' "$kit_json" | tr -d '" ' | tr ',' ' ' || true; } + +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1 + elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d' ' -f1 + else fail "neither sha256sum nor shasum is available"; fi +} + +download() { + if command -v curl >/dev/null 2>&1; then curl -fsSL --retry 3 -o "$2" "$1" + elif command -v wget >/dev/null 2>&1; then wget -q -O "$2" "$1" + else fail "neither curl nor wget is available"; fi +} + +extract_zip() { + if command -v unzip >/dev/null 2>&1; then unzip -q -o "$1" -d "$2" + else tar -xf "$1" -C "$2" || fail "cannot extract '$1': install unzip"; fi +} + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT INT TERM +kit_dir="" +actual_sha="" + +[ -n "$repo" ] || fail "--repo is empty" +recorded_repo=$(json_value repository) +if [ -n "$recorded_repo" ] && [ "$repo" = "DragoAnt/MSBuildKit" ]; then repo="$recorded_repo"; fi + +if [ -n "$source" ]; then + if [ -d "$source" ]; then + [ -f "$source/.toolkit/msbuild/init.props" ] || fail "'$source' is not a kit build: no .toolkit/msbuild/init.props" + kit_dir="$source" + elif [ -f "$source" ]; then + actual_sha=$(sha256_of "$source") + if [ -n "$expected_sha" ] && [ "$actual_sha" != "$expected_sha" ]; then + fail "SHA-256 mismatch for '$source': expected $expected_sha, got $actual_sha" + fi + mkdir -p "$work/kit"; extract_zip "$source" "$work/kit"; kit_dir="$work/kit" + else + fail "--source '$source' does not exist" + fi + [ -n "$version" ] || version=$(sed -n '1p' "$kit_dir/.toolkit/kit.version" 2>/dev/null || true) + [ -n "$version" ] || version="0.0.0-local" +else + [ -n "$version" ] || version=$(json_value version) + if [ -z "$version" ]; then + download "https://api.github.com/repos/$repo/releases/latest" "$work/latest.json" || fail "cannot query the latest release of $repo" + tag=$(sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$work/latest.json" | head -n 1) + [ -n "$tag" ] || fail "no release found in $repo" + version="${tag#v}" + fi + zip_name="msbuildkit-$version.zip" + base="https://github.com/$repo/releases/download/v$version" + say "downloading $base/$zip_name" + download "$base/$zip_name" "$work/$zip_name" || fail "cannot download $base/$zip_name" + download "$base/$zip_name.sha256" "$work/$zip_name.sha256" || fail "cannot download $base/$zip_name.sha256" + published_sha=$(cut -d' ' -f1 "$work/$zip_name.sha256" | tr 'A-F' 'a-f') + actual_sha=$(sha256_of "$work/$zip_name") + [ "$actual_sha" = "$published_sha" ] || fail "SHA-256 mismatch for $zip_name: published $published_sha, downloaded $actual_sha" + pinned_sha=$(json_value sha256) + if [ -z "$expected_sha" ] && [ "$(json_value version)" = "$version" ] && [ -n "$pinned_sha" ]; then expected_sha="$pinned_sha"; fi + if [ -n "$expected_sha" ] && [ "$actual_sha" != "$expected_sha" ]; then + fail "SHA-256 mismatch for $zip_name: expected $expected_sha (kit.json or --sha256), got $actual_sha" + fi + mkdir -p "$work/kit"; extract_zip "$work/$zip_name" "$work/kit"; kit_dir="$work/kit" +fi + +new_toolkit="$kit_dir/.toolkit" +[ -f "$new_toolkit/msbuild/init.props" ] || fail "the kit build has no .toolkit/msbuild/init.props" +[ -f "$new_toolkit/kit.parts" ] || fail "the kit build has no .toolkit/kit.parts" +if [ -d "$toolkit" ] && [ "$(cd "$new_toolkit" && pwd -P)" = "$(cd "$toolkit" && pwd -P)" ]; then fail "the target .toolkit is the kit source itself; run from the repository you want to update, or pass --root"; fi +parts_file="$work/kit.parts" +tr -d '\r' < "$new_toolkit/kit.parts" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$' > "$parts_file" + +part_kind() { awk -v p="$1" '$1==p {print $2}' "$parts_file"; } +part_deps() { awk -v p="$1" '$1==p {for (i=3;i<=NF;i++) print $i}' "$parts_file"; } +part_dir() { if [ "$1" = "Trunk" ]; then echo "DragoAnt.MSBuildKit"; else echo "DragoAnt.MSBuildKit.$1"; fi; } + +optional="" +for p in $(json_parts) $add_parts; do + kind=$(part_kind "$p") + [ -n "$kind" ] || fail "unknown part '$p'; known parts: $(awk '{print $1}' "$parts_file" | tr '\n' ' ')" + case " $optional " in *" $p "*) ;; *) if [ "$kind" = optional ]; then optional="$optional $p"; fi ;; esac +done +for p in $remove_parts; do + kind=$(part_kind "$p") + [ -n "$kind" ] || fail "unknown part '$p'" + [ "$kind" = optional ] || fail "'$p' is a default part and cannot be removed" + optional=$(echo " $optional " | sed "s/ $p / /g") +done + +selected=$(awk '$2=="default" {print $1}' "$parts_file") +pending="$optional" +while [ -n "$(echo $pending)" ]; do + next="" + for p in $pending; do + case " $(echo $selected) " in *" $p "*) continue ;; esac + selected="$selected $p" + for d in $(part_deps "$p"); do next="$next $d"; done + done + pending="$next" +done +recorded="" +for p in $selected; do if [ "$(part_kind "$p")" = optional ]; then recorded="$recorded $p"; fi; done +recorded=$(echo $recorded) + +stage="$work/stage" +mkdir -p "$stage/msbuild" +for f in "$new_toolkit"/msbuild/*; do [ -f "$f" ] && cp "$f" "$stage/msbuild/"; done +for p in $selected; do + d=$(part_dir "$p") + [ -d "$new_toolkit/msbuild/$d" ] || fail "the kit build has no part folder msbuild/$d" + cp -R "$new_toolkit/msbuild/$d" "$stage/msbuild/$d" +done + +old_version=$(json_value version) +say "kit $repo ${old_version:-none} -> $version${actual_sha:+ (sha256 $actual_sha)}" +say "parts: $(echo $selected)" +changes=0 +if [ -d "$toolkit/msbuild" ]; then + (cd "$toolkit/msbuild" && find . -type f | sort) > "$work/old.lst" +else + : > "$work/old.lst" +fi +(cd "$stage/msbuild" && find . -type f | sort) > "$work/new.lst" +for f in $(comm -13 "$work/old.lst" "$work/new.lst"); do say " add .toolkit/msbuild/${f#./}"; changes=$((changes+1)); done +for f in $(comm -23 "$work/old.lst" "$work/new.lst"); do say " remove .toolkit/msbuild/${f#./}"; changes=$((changes+1)); done +for f in $(comm -12 "$work/old.lst" "$work/new.lst"); do + cmp -s "$toolkit/msbuild/$f" "$stage/msbuild/$f" || { say " change .toolkit/msbuild/${f#./}"; changes=$((changes+1)); } +done + +dbp="$root/Directory.Build.props" +dbt="$root/Directory.Build.targets" + +if [ "$dry_run" -eq 1 ]; then + say "$changes file(s) under .toolkit/msbuild would change" + [ -f "$dbp" ] || say " would create Directory.Build.props" + [ -f "$dbt" ] || say " would create Directory.Build.targets" + say "dry run: nothing changed" + exit 0 +fi + +mkdir -p "$toolkit" +rm -rf "$toolkit/msbuild" +cp -R "$stage/msbuild" "$toolkit/msbuild" +if [ -d "$new_toolkit/res" ]; then mkdir -p "$toolkit/res"; cp -R "$new_toolkit/res/." "$toolkit/res/"; fi +cp "$new_toolkit/kit.parts" "$toolkit/kit.parts" +rm -f "$toolkit/kit.version" +say "$changes file(s) under .toolkit/msbuild changed" + +parts_json="" +for p in $recorded; do parts_json="$parts_json${parts_json:+, }\"$p\""; done +printf '{\n "repository": "%s",\n "version": "%s",\n "sha256": "%s",\n "parts": [%s]\n}\n' \ + "$repo" "$version" "$actual_sha" "$parts_json" > "$kit_json" + +if [ ! -f "$dbp" ]; then + printf '\n\n \n\n\n' > "$dbp" + say "created Directory.Build.props" +elif ! grep -q '\.toolkit[/\\]msbuild[/\\]init\.props' "$dbp"; then + say "add to Directory.Build.props: " +fi +if [ ! -f "$dbt" ]; then + printf '\n\n \n\n\n' > "$dbt" + say "created Directory.Build.targets" +elif ! grep -q '\.toolkit[/\\]msbuild[/\\]init\.targets' "$dbt"; then + say "add to Directory.Build.targets: " +fi +for f in update.ps1 update.sh; do if [ -f "$new_toolkit/$f" ]; then cp "$new_toolkit/$f" "$toolkit/$f"; fi; done +say "done: DragoAnt.MSBuildKit $version installed in $toolkit" +exit 0 +} diff --git a/manager/Directory.Build.props b/manager/Directory.Build.props new file mode 100644 index 0000000..6927d4e --- /dev/null +++ b/manager/Directory.Build.props @@ -0,0 +1,9 @@ + + + + net8.0;net10.0 + + + + + diff --git a/manager/Directory.Build.targets b/manager/Directory.Build.targets new file mode 100644 index 0000000..07200a8 --- /dev/null +++ b/manager/Directory.Build.targets @@ -0,0 +1,5 @@ + + + + + diff --git a/manager/Directory.Packages.props b/manager/Directory.Packages.props new file mode 100644 index 0000000..4bb3b86 --- /dev/null +++ b/manager/Directory.Packages.props @@ -0,0 +1,17 @@ + + + + true + + + + + + + + + + + + + diff --git a/manager/Directory.Version.props b/manager/Directory.Version.props new file mode 100644 index 0000000..a64e0d3 --- /dev/null +++ b/manager/Directory.Version.props @@ -0,0 +1,7 @@ + + + + 0.3.0 + + + diff --git a/manager/DragoAnt.MSBuildKit.Manager.slnx b/manager/DragoAnt.MSBuildKit.Manager.slnx new file mode 100644 index 0000000..8176523 --- /dev/null +++ b/manager/DragoAnt.MSBuildKit.Manager.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/manager/global.json b/manager/global.json new file mode 100644 index 0000000..1d364c6 --- /dev/null +++ b/manager/global.json @@ -0,0 +1,9 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + }, + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Cli/Commands/StatusCommand.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Cli/Commands/StatusCommand.cs new file mode 100644 index 0000000..796ab33 --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Cli/Commands/StatusCommand.cs @@ -0,0 +1,25 @@ +using System.Text.Json; +using DragoAnt.MSBuildKit.Manager.Cli.Settings; +using Microsoft.Extensions.Logging; +using Spectre.Console.Cli; + +namespace DragoAnt.MSBuildKit.Manager.Cli.Commands; + +internal sealed class StatusCommand(ToolConsole _console, ILogger _logger) : Command +{ + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true }; + + public override int Execute(CommandContext context, StatusSettings settings, CancellationToken cancellationToken) + { + _logger.LogInformation("{Tool} {Version}", ToolInfo.CommandName, ToolInfo.Version); + + _console.Out.WriteLine(settings.Json + ? JsonSerializer.Serialize(new StatusJson(new ToolJson(ToolInfo.CommandName, ToolInfo.Version)), JsonOptions) + : $"{ToolInfo.CommandName} {ToolInfo.Version}"); + return 0; + } + + private sealed record StatusJson(ToolJson Tool); + + private sealed record ToolJson(string Name, string Version); +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Cli/GlobalOptions.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Cli/GlobalOptions.cs new file mode 100644 index 0000000..d7889f9 --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Cli/GlobalOptions.cs @@ -0,0 +1,40 @@ +namespace DragoAnt.MSBuildKit.Manager.Cli; + +/// Options every command accepts, anywhere on the command line. They are read before the command +/// parser runs, because the logo and the log are set up first. +/// --no-logo: never print the logo. +/// --diagnostic: mirror debug events with --log-to-console. +/// --log-to-console: mirror the log to stderr. +/// --no-color: plain text output. +public sealed record GlobalOptions(bool NoLogo = false, bool Diagnostic = false, bool LogToConsole = false, bool NoColor = false) +{ + /// Reads the global flags and returns the arguments left for the commands, in their order. + public static (GlobalOptions Options, string[] Remaining) Parse(IReadOnlyList args) + { + var options = new GlobalOptions(); + var remaining = new List(args.Count); + foreach (var arg in args) + { + switch (arg.ToLowerInvariant()) + { + case "--no-logo": + options = options with { NoLogo = true }; + break; + case "--diagnostic": + options = options with { Diagnostic = true }; + break; + case "--log-to-console": + options = options with { LogToConsole = true }; + break; + case "--no-color": + options = options with { NoColor = true }; + break; + default: + remaining.Add(arg); + break; + } + } + + return (options, remaining.ToArray()); + } +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Cli/Logo.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Cli/Logo.cs new file mode 100644 index 0000000..bd9dad7 --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Cli/Logo.cs @@ -0,0 +1,22 @@ +namespace DragoAnt.MSBuildKit.Manager.Cli; + +internal static class Logo +{ + private const string Ink = "\x1b[38;2;46;139;87m"; + private const string Accent = "\x1b[38;2;218;165;32m"; + private const string Dim = "\x1b[90m"; + private const string Reset = "\x1b[0m"; + + // stdout carries results only, so the logo goes to stderr, and only when a person is watching it. + public static bool ShouldWrite(GlobalOptions options, bool errorIsTerminal) => !options.NoLogo && errorIsTerminal; + + public static void Write(TextWriter error, string version, bool color) + { + string Paint(string code, string text) => color ? code + text + Reset : text; + + error.WriteLine(" " + Paint(Ink, "╔╦╗╔═╗╦╔═╦╔╦╗") + " " + Paint(Accent, $"{ToolInfo.CommandName} {version}")); + error.WriteLine(" " + Paint(Ink, "║║║╚═╗╠╩╗║ ║ ") + " " + Paint(Dim, "DragoAnt MSBuildKit for .NET repositories")); + error.WriteLine(" " + Paint(Ink, "╩ ╩╚═╝╩ ╩╩ ╩ ")); + error.WriteLine(); + } +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Cli/Settings/StatusSettings.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Cli/Settings/StatusSettings.cs new file mode 100644 index 0000000..0cf3b47 --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Cli/Settings/StatusSettings.cs @@ -0,0 +1,13 @@ +using System.ComponentModel; +using Spectre.Console.Cli; + +namespace DragoAnt.MSBuildKit.Manager.Cli.Settings; + +/// Options of status. +public sealed class StatusSettings : CommandSettings +{ + /// Print JSON instead of text. + [CommandOption("--json")] + [Description("Print JSON instead of text.")] + public bool Json { get; init; } +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Cli/ToolConsole.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Cli/ToolConsole.cs new file mode 100644 index 0000000..2d14352 --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Cli/ToolConsole.cs @@ -0,0 +1,11 @@ +namespace DragoAnt.MSBuildKit.Manager.Cli; + +/// Where a run writes: carries results only; the logo, progress and errors go to . +/// Results — what a script or --json reader consumes. +/// Everything meant for a person. +/// True when is an interactive terminal. +public sealed record ToolConsole(TextWriter Out, TextWriter Error, bool ErrorIsTerminal) +{ + /// The process's own streams. + public static ToolConsole System => new(Console.Out, Console.Error, !Console.IsErrorRedirected); +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Cli/ToolInfo.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Cli/ToolInfo.cs new file mode 100644 index 0000000..4fd89de --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Cli/ToolInfo.cs @@ -0,0 +1,24 @@ +using System.Reflection; + +namespace DragoAnt.MSBuildKit.Manager.Cli; + +/// The tool's name and version. +public static class ToolInfo +{ + /// The command a user types. + public const string CommandName = "mskit-manager"; + + /// The package version, without build metadata. + public static string Version { get; } = ReadVersion(); + + private static string ReadVersion() + { + var assembly = typeof(ToolInfo).Assembly; + var informational = assembly.GetCustomAttribute()?.InformationalVersion; + if (informational is not null) + return informational.Split('+')[0]; + + var version = assembly.GetName().Version; + return version is null ? "0.0.0" : $"{version.Major}.{version.Minor}.{version.Build}"; + } +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/DragoAnt.MSBuildKit.Manager.csproj b/manager/src/DragoAnt.MSBuildKit.Manager/DragoAnt.MSBuildKit.Manager.csproj new file mode 100644 index 0000000..659e5c3 --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/DragoAnt.MSBuildKit.Manager.csproj @@ -0,0 +1,22 @@ + + + + Exe + true + mskit-manager + Major + en + A .NET tool that installs, updates and migrates DragoAnt.MSBuildKit in a repository. + msbuild;dotnet-tool;build;nuget;props;targets + + + + + + + + + + + + diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Infrastructure/DependencyInjectionTypeRegistrar.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Infrastructure/DependencyInjectionTypeRegistrar.cs new file mode 100644 index 0000000..19abdfa --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Infrastructure/DependencyInjectionTypeRegistrar.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console.Cli; + +namespace DragoAnt.MSBuildKit.Manager.Infrastructure; + +/// Lets resolve commands from an ; everything is a singleton. +public sealed class DependencyInjectionTypeRegistrar(IServiceCollection _services) : ITypeRegistrar +{ + /// + public ITypeResolver Build() => new DependencyInjectionTypeResolver(_services.BuildServiceProvider()); + + /// + public void Register(Type service, Type implementation) => _services.AddSingleton(service, implementation); + + /// + public void RegisterInstance(Type service, object implementation) => _services.AddSingleton(service, implementation); + + /// + public void RegisterLazy(Type service, Func factory) + { + ArgumentNullException.ThrowIfNull(factory); + _services.AddSingleton(service, _ => factory()); + } +} + +internal sealed class DependencyInjectionTypeResolver(ServiceProvider _provider) : ITypeResolver, IDisposable +{ + // Spectre expects null for an unregistered type; throwing reads as a crashed resolver. + public object? Resolve(Type? type) => type is null ? null : _provider.GetService(type); + + public void Dispose() => _provider.Dispose(); +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Infrastructure/IProcessRunner.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Infrastructure/IProcessRunner.cs new file mode 100644 index 0000000..0bf6068 --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Infrastructure/IProcessRunner.cs @@ -0,0 +1,23 @@ +namespace DragoAnt.MSBuildKit.Manager.Infrastructure; + +/// Starts child processes; the seam tests replace. +public interface IProcessRunner +{ + /// Starts , captures both output streams and awaits exit. + /// Executable to launch, resolved through PATH. + /// Command-line arguments for the child. + /// Working directory of the child; the current directory when null. + /// Cancels the wait. + Task RunAsync(string fileName, string arguments, string? workingDirectory = null, CancellationToken ct = default); + + /// As , also invoking a callback per output line as it arrives. + /// Callbacks run on thread-pool threads; one that throws is ignored so the streams keep draining. + /// Executable to launch, resolved through PATH. + /// Command-line arguments for the child. + /// Working directory of the child; the current directory when null. + /// Called once per standard-output line. + /// Called once per standard-error line. + /// Cancels the wait. + Task RunWithCallbacksAsync(string fileName, string arguments, string? workingDirectory, + Action onStdout, Action onStderr, CancellationToken ct = default); +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Infrastructure/PathUtils.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Infrastructure/PathUtils.cs new file mode 100644 index 0000000..d7dcfbc --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Infrastructure/PathUtils.cs @@ -0,0 +1,17 @@ +namespace DragoAnt.MSBuildKit.Manager.Infrastructure; + +/// Cross-platform path helpers. +public static class PathUtils +{ + /// Forward slashes, no leading slash — the shape of a path inside a package. + public static string NormalizePath(string path) => path.Replace('\\', '/').TrimStart('/'); + + /// True when resolves to or a path beneath it. + public static bool IsPathUnderRoot(string path, string rootPath) + { + var relative = Path.GetRelativePath(Path.GetFullPath(rootPath), Path.GetFullPath(path)); + return !Path.IsPathRooted(relative) + && relative != ".." + && !relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal); + } +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Infrastructure/ProcessResult.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Infrastructure/ProcessResult.cs new file mode 100644 index 0000000..e09bf5e --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Infrastructure/ProcessResult.cs @@ -0,0 +1,49 @@ +namespace DragoAnt.MSBuildKit.Manager.Infrastructure; + +/// Captured outcome of a child-process run. +public sealed class ProcessResult +{ + /// Process exit code reported by the OS. + public int ExitCode { get; init; } + + /// Captured standard output, trailing whitespace trimmed. + public string StdOut { get; init; } = ""; + + /// Captured standard error, trailing whitespace trimmed. + public string StdErr { get; init; } = ""; + + /// True when is zero. + public bool Success => ExitCode == 0; + + /// rendered for a reader, naming the POSIX signal behind it where there is one. + public string ExitDescription => DescribeExitCode(ExitCode); + + /// Renders , expanding the 128+N range into the signal that produced it. + /// A child killed by a signal leaves only 128 + the signal number and nothing on stderr. A script can return + /// that number on its own, so the signal is named as the shape of the code, not asserted as what happened. + public static string DescribeExitCode(int exitCode) => DescribeExitCode(exitCode, OperatingSystem.IsWindows()); + + internal static string DescribeExitCode(int exitCode, bool isWindows) + { + if (isWindows || exitCode <= 128 || exitCode > 128 + 64) + return $"exit {exitCode}"; + + var signal = exitCode - 128; + var name = signal switch + { + 1 => "SIGHUP", + 2 => "SIGINT", + 3 => "SIGQUIT", + 4 => "SIGILL", + 6 => "SIGABRT", + 8 => "SIGFPE", + 9 => "SIGKILL", + 11 => "SIGSEGV", + 13 => "SIGPIPE", + 15 => "SIGTERM", + _ => $"signal {signal}", + }; + + return $"exit {exitCode} — 128+{signal}, the shape of a child killed by {name}"; + } +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Infrastructure/ProcessRunner.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Infrastructure/ProcessRunner.cs new file mode 100644 index 0000000..62d96dc --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Infrastructure/ProcessRunner.cs @@ -0,0 +1,78 @@ +using System.Diagnostics; +using System.Text; + +namespace DragoAnt.MSBuildKit.Manager.Infrastructure; + +/// Runs dotnet, git and other children through , stdin closed. +public sealed class ProcessRunner : IProcessRunner +{ + /// + public Task RunAsync(string fileName, string arguments, string? workingDirectory = null, CancellationToken ct = default) => + RunWithCallbacksAsync(fileName, arguments, workingDirectory, static _ => { }, static _ => { }, ct); + + /// + public async Task RunWithCallbacksAsync(string fileName, string arguments, string? workingDirectory, + Action onStdout, Action onStderr, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(onStdout); + ArgumentNullException.ThrowIfNull(onStderr); + + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + WorkingDirectory = workingDirectory ?? Directory.GetCurrentDirectory(), + RedirectStandardOutput = true, + RedirectStandardError = true, + // A child that reads stdin would otherwise block on the shared console and never exit. + RedirectStandardInput = true, + UseShellExecute = false, + CreateNoWindow = true, + }, + }; + var stdout = new StringBuilder(); + var stderr = new StringBuilder(); + process.OutputDataReceived += (_, e) => Collect(e.Data, stdout, onStdout); + process.ErrorDataReceived += (_, e) => Collect(e.Data, stderr, onStderr); + + process.Start(); + try + { + process.StandardInput.Close(); + } + catch (IOException) + { + } + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + await process.WaitForExitAsync(ct); + + return new ProcessResult + { + ExitCode = process.ExitCode, + StdOut = stdout.ToString().TrimEnd(), + StdErr = stderr.ToString().TrimEnd(), + }; + } + + private static void Collect(string? line, StringBuilder buffer, Action callback) + { + if (line is null) + return; + + lock (buffer) + buffer.AppendLine(line); + + try + { + callback(line); + } + catch (Exception) + { + // The streams must keep draining or the child blocks on a full pipe. + } + } +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Logging/CommandLogInterceptor.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Logging/CommandLogInterceptor.cs new file mode 100644 index 0000000..31dc58f --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Logging/CommandLogInterceptor.cs @@ -0,0 +1,9 @@ +using Spectre.Console.Cli; + +namespace DragoAnt.MSBuildKit.Manager.Logging; + +/// Names the log file after the command the parser chose, so a new command needs no registration here. +internal sealed class CommandLogInterceptor(LogSession _session) : ICommandInterceptor +{ + public void Intercept(CommandContext context, CommandSettings settings) => _session.Open(context.Name); +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Logging/LogSession.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Logging/LogSession.cs new file mode 100644 index 0000000..96d5002 --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Logging/LogSession.cs @@ -0,0 +1,172 @@ +using System.Globalization; +using Microsoft.Extensions.Logging; +using Serilog; +using Serilog.Core; +using Serilog.Events; +using Serilog.Formatting.Compact; +using Serilog.Formatting.Display; + +namespace DragoAnt.MSBuildKit.Manager.Logging; + +/// The run's log: one CompactJson file per invocation, named after the parsed command, newest +/// kept. Events logged before the command is known are held and written on . +public sealed class LogSession : IDisposable +{ + /// How many log files the folder keeps, the new one included. + public const int KeptFiles = 20; + + private const string FallbackCommand = "tool"; + + private readonly string _directory; + private readonly DeferredFileSink _file = new(); + private readonly Logger _logger; + + private LogSession(string directory, LogLevel consoleLevel, TextWriter? consoleWriter) + { + _directory = directory; + var config = new LoggerConfiguration() + .MinimumLevel.Verbose() + .Enrich.FromLogContext() + .WriteTo.Sink(_file); + if (consoleWriter is not null) + config = config.WriteTo.Sink(new TextWriterSink(consoleWriter), ToSerilogLevel(consoleLevel)); + _logger = config.CreateLogger(); + } + + /// The default folder: <system temp>/mskit-manager/logs. + public static string DefaultDirectory => Path.Combine(Path.GetTempPath(), "mskit-manager", "logs"); + + /// The process logger. + public Serilog.ILogger Logger => _logger; + + /// The log file, or null until . + public string? FilePath => _file.Path; + + /// Starts a session; nothing touches the disk until . + /// Folder for the log files. + /// Minimum level mirrored to . + /// Mirror of the log for --log-to-console; null for none. + public static LogSession Create(string logDirectory, LogLevel consoleLevel, TextWriter? consoleWriter) => + new(logDirectory, consoleLevel, consoleWriter); + + /// Creates the file for and writes the held events; later calls do nothing. + public void Open(string commandName) => _file.Open(_directory, commandName); + + /// The log file path, opening it under the fallback name when no command was parsed. + public string EnsureOpen() + { + _file.Open(_directory, FallbackCommand); + return _file.Path!; + } + + /// Tells the user where the log of a failed run is. + public static void WriteFailureFooter(TextWriter error, string logFilePath) + { + error.WriteLine(); + error.WriteLine($"mskit-manager failed — see {logFilePath}"); + } + + /// + public void Dispose() + { + _logger.Dispose(); + _file.Dispose(); + } + + private static LogEventLevel ToSerilogLevel(LogLevel level) => level switch + { + LogLevel.Trace => LogEventLevel.Verbose, + LogLevel.Debug => LogEventLevel.Debug, + LogLevel.Information => LogEventLevel.Information, + LogLevel.Warning => LogEventLevel.Warning, + LogLevel.Error => LogEventLevel.Error, + _ => LogEventLevel.Fatal, + }; + + private sealed class TextWriterSink(TextWriter _writer) : ILogEventSink + { + private readonly MessageTemplateTextFormatter _formatter = new("{Message:lj}{NewLine}{Exception}", CultureInfo.InvariantCulture); + + public void Emit(LogEvent logEvent) + { + lock (_writer) + _formatter.Format(logEvent, _writer); + } + } + + private sealed class DeferredFileSink : ILogEventSink, IDisposable + { + private readonly object _gate = new(); + private readonly List _pending = []; + private Logger? _file; + + public string? Path { get; private set; } + + public void Emit(LogEvent logEvent) + { + lock (_gate) + { + if (_file is null) + _pending.Add(logEvent); + else + _file.Write(logEvent); + } + } + + public void Open(string directory, string commandName) + { + lock (_gate) + { + if (_file is not null) + return; + + Directory.CreateDirectory(directory); + Prune(directory); + Path = NewFilePath(directory, string.IsNullOrWhiteSpace(commandName) ? FallbackCommand : commandName); + _file = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.File(new CompactJsonFormatter(), Path) + .CreateLogger(); + foreach (var pending in _pending) + _file.Write(pending); + _pending.Clear(); + } + } + + public void Dispose() + { + lock (_gate) + _file?.Dispose(); + } + + private static string NewFilePath(string directory, string commandName) + { + var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss-fff", CultureInfo.InvariantCulture); + var path = System.IO.Path.Combine(directory, $"{stamp}-{commandName}.log"); + for (var n = 2; File.Exists(path); n++) + path = System.IO.Path.Combine(directory, $"{stamp}-{commandName}-{n}.log"); + return path; + } + + private static void Prune(string directory) + { + var stale = Directory.GetFiles(directory, "*.log") + .OrderByDescending(System.IO.Path.GetFileName, StringComparer.Ordinal) + .Skip(KeptFiles - 1); + foreach (var file in stale) + { + try + { + File.Delete(file); + } + catch (IOException) + { + // Another run still writes it; the next run prunes it. + } + catch (UnauthorizedAccessException) + { + } + } + } + } +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Program.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Program.cs new file mode 100644 index 0000000..7fd5a96 --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Program.cs @@ -0,0 +1,84 @@ +using System.Text; +using DragoAnt.MSBuildKit.Manager.Cli; +using DragoAnt.MSBuildKit.Manager.Cli.Commands; +using DragoAnt.MSBuildKit.Manager.Infrastructure; +using DragoAnt.MSBuildKit.Manager.Logging; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Serilog; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace DragoAnt.MSBuildKit.Manager; + +/// The mskit-manager host: logo, log, services and the command app. +public static class Program +{ + /// Exit code of a usage error: an unknown command or option, a bad value. + public const int UsageError = 2; + + /// Runs the command line and exits with its code. + public static int Main(string[] args) + { + Console.OutputEncoding = Encoding.UTF8; + var exitCode = Run(args, ToolConsole.System, LogSession.DefaultDirectory); + Console.Out.Flush(); + Console.Error.Flush(); + // With a real console attached, a console-input thread can outlive the work and keep the process alive. + Environment.Exit(exitCode); + return exitCode; + } + + internal static int Run(string[] args, ToolConsole console, string logDirectory) + { + var (options, commandArgs) = GlobalOptions.Parse(args); + if (Logo.ShouldWrite(options, console.ErrorIsTerminal)) + Logo.Write(console.Error, ToolInfo.Version, color: !options.NoColor); + + using var session = LogSession.Create(logDirectory, + options.Diagnostic ? LogLevel.Debug : LogLevel.Information, + options.LogToConsole ? console.Error : null); + + var services = new ServiceCollection(); + services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Trace).AddSerilog(session.Logger)); + services.AddSingleton(console); + services.AddSingleton(); + + var app = new CommandApp(new DependencyInjectionTypeRegistrar(services)); + app.Configure(config => + { + config.SetApplicationName(ToolInfo.CommandName); + config.SetApplicationVersion(ToolInfo.Version); + config.ConfigureConsole(CreateAnsiConsole(console.Out, options.NoColor)); + config.SetInterceptor(new CommandLogInterceptor(session)); + config.SetExceptionHandler((exception, _) => HandleException(exception, console, session)); + + config.AddCommand("status") + .WithDescription("Show the tool version."); + }); + + return app.Run(commandArgs); + } + + private static int HandleException(Exception exception, ToolConsole console, LogSession session) + { + if (exception is CommandAppException) + { + console.Error.WriteLine($"error: {exception.Message}"); + return UsageError; + } + + session.Logger.Fatal(exception, "{Tool} failed", ToolInfo.CommandName); + LogSession.WriteFailureFooter(console.Error, session.EnsureOpen()); + return 1; + } + + private static IAnsiConsole CreateAnsiConsole(TextWriter output, bool noColor) => + AnsiConsole.Create(new AnsiConsoleSettings + { + Out = new AnsiConsoleOutput(output), + Ansi = noColor ? AnsiSupport.No : AnsiSupport.Detect, + ColorSystem = noColor ? ColorSystemSupport.NoColors : ColorSystemSupport.Detect, + Interactive = InteractionSupport.No, + }); +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/package.readme.md b/manager/src/DragoAnt.MSBuildKit.Manager/package.readme.md new file mode 100644 index 0000000..5e26dae --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/package.readme.md @@ -0,0 +1,21 @@ +# DragoAnt.MSBuildKit.Manager + +`mskit-manager` is the .NET tool behind [DragoAnt.MSBuildKit](https://github.com/DragoAnt/MSBuildKit): it installs, updates and migrates the kit in a repository. + +## Install + +```sh +dotnet new tool-manifest +dotnet tool install DragoAnt.MSBuildKit.Manager +dotnet mskit-manager status --json +``` + +## Commands + +| Command | Does | +| --- | --- | +| `status [--json]` | prints the tool version | + +Global options: `--no-logo`, `--diagnostic`, `--log-to-console`, `--no-color`. The logo and progress go to stderr; stdout carries only results. + +This is an early build: the install, update and migrate commands arrive in later releases. diff --git a/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Cli/GlobalOptionsTests.cs b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Cli/GlobalOptionsTests.cs new file mode 100644 index 0000000..3fadb22 --- /dev/null +++ b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Cli/GlobalOptionsTests.cs @@ -0,0 +1,33 @@ +using DragoAnt.MSBuildKit.Manager.Cli; + +namespace DragoAnt.MSBuildKit.Manager.Tests.Cli; + +public sealed class GlobalOptionsTests +{ + [Fact] + public void Parse_ReadsEveryFlagAndLeavesTheRestForTheCommands() + { + var (options, remaining) = GlobalOptions.Parse(["--no-logo", "status", "--diagnostic", "--json", "--log-to-console", "--no-color"]); + + options.Should().Be(new GlobalOptions(NoLogo: true, Diagnostic: true, LogToConsole: true, NoColor: true)); + remaining.Should().Equal("status", "--json"); + } + + [Fact] + public void Parse_IsCaseInsensitiveAndRemovesEveryOccurrence() + { + var (options, remaining) = GlobalOptions.Parse(["--NO-LOGO", "status", "--no-logo"]); + + options.NoLogo.Should().BeTrue(); + remaining.Should().Equal("status"); + } + + [Fact] + public void Parse_KeepsAValueThatOnlyContainsAFlag() + { + var (options, remaining) = GlobalOptions.Parse(["status", "--name=--no-logo"]); + + options.NoLogo.Should().BeFalse(); + remaining.Should().Equal("status", "--name=--no-logo"); + } +} diff --git a/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Cli/LogoTests.cs b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Cli/LogoTests.cs new file mode 100644 index 0000000..d8f0f00 --- /dev/null +++ b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Cli/LogoTests.cs @@ -0,0 +1,33 @@ +using DragoAnt.MSBuildKit.Manager.Cli; + +namespace DragoAnt.MSBuildKit.Manager.Tests.Cli; + +public sealed class LogoTests +{ + [Theory] + [InlineData(false, true, true)] + [InlineData(true, true, false)] + [InlineData(false, false, false)] + public void ShouldWrite_OnlyOnATerminalWithoutNoLogo(bool noLogo, bool errorIsTerminal, bool expected) => + Logo.ShouldWrite(new GlobalOptions(NoLogo: noLogo), errorIsTerminal).Should().Be(expected); + + [Fact] + public void Write_NamesTheToolAndVersion_WithoutColorWhenAsked() + { + var error = new StringWriter(); + + Logo.Write(error, "1.2.3", color: false); + + error.ToString().Should().Contain("mskit-manager 1.2.3").And.Contain("MSBuildKit").And.NotContain("\x1b["); + } + + [Fact] + public void Write_WithColor_ResetsTheTerminalAtTheEnd() + { + var error = new StringWriter(); + + Logo.Write(error, "1.2.3", color: true); + + error.ToString().TrimEnd().Should().EndWith("\x1b[0m"); + } +} diff --git a/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/DragoAnt.MSBuildKit.Manager.Tests.csproj b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/DragoAnt.MSBuildKit.Manager.Tests.csproj new file mode 100644 index 0000000..07a1f30 --- /dev/null +++ b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/DragoAnt.MSBuildKit.Manager.Tests.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Infrastructure/DependencyInjectionTypeRegistrarTests.cs b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Infrastructure/DependencyInjectionTypeRegistrarTests.cs new file mode 100644 index 0000000..41fb7b3 --- /dev/null +++ b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Infrastructure/DependencyInjectionTypeRegistrarTests.cs @@ -0,0 +1,27 @@ +using DragoAnt.MSBuildKit.Manager.Infrastructure; +using Microsoft.Extensions.DependencyInjection; + +namespace DragoAnt.MSBuildKit.Manager.Tests.Infrastructure; + +public sealed class DependencyInjectionTypeRegistrarTests +{ + [Fact] + public void Build_ResolvesRegisteredTypesAndServicesAddedBefore() + { + var services = new ServiceCollection(); + services.AddSingleton("added before"); + var registrar = new DependencyInjectionTypeRegistrar(services); + registrar.Register(typeof(IProcessRunner), typeof(ProcessRunner)); + registrar.RegisterLazy(typeof(Uri), () => new Uri("https://example.org/")); + + var resolver = registrar.Build(); + + resolver.Resolve(typeof(IProcessRunner)).Should().BeOfType(); + resolver.Resolve(typeof(Uri)).Should().Be(new Uri("https://example.org/")); + resolver.Resolve(typeof(string)).Should().Be("added before"); + } + + [Fact] + public void Resolve_WhenTypeIsNotRegistered_ReturnsNull() => + new DependencyInjectionTypeRegistrar(new ServiceCollection()).Build().Resolve(typeof(IProcessRunner)).Should().BeNull(); +} diff --git a/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Infrastructure/PathUtilsTests.cs b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Infrastructure/PathUtilsTests.cs new file mode 100644 index 0000000..b682068 --- /dev/null +++ b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Infrastructure/PathUtilsTests.cs @@ -0,0 +1,33 @@ +using DragoAnt.MSBuildKit.Manager.Infrastructure; + +namespace DragoAnt.MSBuildKit.Manager.Tests.Infrastructure; + +public sealed class PathUtilsTests +{ + private static readonly string Root = Path.Combine(Path.GetTempPath(), "mskit-root"); + + [Theory] + [InlineData("a/b.txt", true)] + [InlineData(".", true)] + [InlineData("../mskit-root-sibling/x.txt", false)] + [InlineData("../x.txt", false)] + [InlineData("a/../../x.txt", false)] + public void IsPathUnderRoot_ComparesTheResolvedPath(string relative, bool expected) => + PathUtils.IsPathUnderRoot(Path.Combine(Root, relative), Root).Should().Be(expected); + + [Fact] + public void IsPathUnderRoot_WhenOnAnotherDrive_IsFalse() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "drive letters exist only on Windows"); + var drive = char.ToUpperInvariant(Path.GetPathRoot(Root)![0]) == 'Z' ? 'Y' : 'Z'; + + PathUtils.IsPathUnderRoot($@"{drive}:\elsewhere\x.txt", Root).Should().BeFalse(); + } + + [Theory] + [InlineData(@"\msbuild\a.props", "msbuild/a.props")] + [InlineData("/msbuild/a.props", "msbuild/a.props")] + [InlineData("msbuild/a.props", "msbuild/a.props")] + public void NormalizePath_UsesForwardSlashesWithoutLeadingSlash(string input, string expected) => + PathUtils.NormalizePath(input).Should().Be(expected); +} diff --git a/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Infrastructure/ProcessResultExitDescriptionTests.cs b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Infrastructure/ProcessResultExitDescriptionTests.cs new file mode 100644 index 0000000..7cd8c26 --- /dev/null +++ b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Infrastructure/ProcessResultExitDescriptionTests.cs @@ -0,0 +1,40 @@ +using DragoAnt.MSBuildKit.Manager.Infrastructure; + +namespace DragoAnt.MSBuildKit.Manager.Tests.Infrastructure; + +public sealed class ProcessResultExitDescriptionTests +{ + [Theory] + [InlineData(139, "SIGSEGV")] + [InlineData(137, "SIGKILL")] + [InlineData(143, "SIGTERM")] + [InlineData(134, "SIGABRT")] + [InlineData(141, "SIGPIPE")] + public void DescribeExitCode_WhenUnixSignalRange_NamesTheSignal(int exitCode, string signal) + { + var described = ProcessResult.DescribeExitCode(exitCode, isWindows: false); + + described.Should().Contain(exitCode.ToString()).And.Contain(signal); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(3)] + [InlineData(128)] + [InlineData(255)] + public void DescribeExitCode_WhenOutsideSignalRange_StaysPlainExitCode(int exitCode) => + ProcessResult.DescribeExitCode(exitCode, isWindows: false).Should().Be($"exit {exitCode}"); + + [Fact] + public void DescribeExitCode_WhenSignalUnmapped_StillReportsTheSignalNumber() => + ProcessResult.DescribeExitCode(128 + 31, isWindows: false).Should().Contain("signal 31"); + + [Fact] + public void DescribeExitCode_OnWindows_NeverReadsASignal() => + ProcessResult.DescribeExitCode(139, isWindows: true).Should().Be("exit 139"); + + [Fact] + public void ExitDescription_ReportsTheInstanceExitCode() => + new ProcessResult { ExitCode = 0 }.ExitDescription.Should().Be("exit 0"); +} diff --git a/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Infrastructure/ProcessRunnerTests.cs b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Infrastructure/ProcessRunnerTests.cs new file mode 100644 index 0000000..71f55e4 --- /dev/null +++ b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Infrastructure/ProcessRunnerTests.cs @@ -0,0 +1,36 @@ +using DragoAnt.MSBuildKit.Manager.Infrastructure; + +namespace DragoAnt.MSBuildKit.Manager.Tests.Infrastructure; + +public sealed class ProcessRunnerTests +{ + [Fact] + public async Task RunAsync_WhenChildSucceeds_CapturesStdOutAndExitCode() + { + var result = await new ProcessRunner().RunAsync("dotnet", "--version", ct: TestContext.Current.CancellationToken); + + result.Success.Should().BeTrue(result.StdErr); + result.StdOut.Should().MatchRegex(@"^\d+\.\d+\.\d+"); + } + + [Fact] + public async Task RunWithCallbacksAsync_StreamsEveryLineAndStillCapturesIt() + { + var lines = new List(); + + var result = await new ProcessRunner().RunWithCallbacksAsync( + "dotnet", "--version", null, line => { lock (lines) lines.Add(line); }, _ => { }, + TestContext.Current.CancellationToken); + + lines.Should().ContainSingle().Which.Should().Be(result.StdOut); + } + + [Fact] + public async Task RunAsync_WhenChildFails_ReportsItsExitCode() + { + var result = await new ProcessRunner().RunAsync("dotnet", "no-such-command-xyz", ct: TestContext.Current.CancellationToken); + + result.Success.Should().BeFalse(); + result.ExitCode.Should().NotBe(0); + } +} diff --git a/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Logging/LogSessionTests.cs b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Logging/LogSessionTests.cs new file mode 100644 index 0000000..42f7b70 --- /dev/null +++ b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Logging/LogSessionTests.cs @@ -0,0 +1,108 @@ +using DragoAnt.MSBuildKit.Manager.Logging; +using Microsoft.Extensions.Logging; + +namespace DragoAnt.MSBuildKit.Manager.Tests.Logging; + +public sealed class LogSessionTests +{ + [Fact] + public void Open_NamesTheFileAfterTheCommand() + { + using var dir = new TempDirectory(); + string path; + using (var session = LogSession.Create(dir.Path, LogLevel.Information, consoleWriter: null)) + { + session.Open("status"); + path = session.FilePath!; + } + + Path.GetDirectoryName(path).Should().Be(dir.Path); + Path.GetFileName(path).Should().MatchRegex(@"^\d{8}-\d{6}-\d{3}-status\.log$"); + } + + [Fact] + public void Open_KeepsTheNewestTwentyFiles() + { + using var dir = new TempDirectory(); + for (var i = 0; i < 25; i++) + File.WriteAllText(Path.Combine(dir.Path, $"20200101-0000{i:00}-000-old.log"), "x"); + File.WriteAllText(Path.Combine(dir.Path, "notes.txt"), "not a log"); + + using (var session = LogSession.Create(dir.Path, LogLevel.Information, consoleWriter: null)) + session.Open("status"); + + var logs = Directory.GetFiles(dir.Path, "*.log").Select(Path.GetFileName).ToList(); + logs.Should().HaveCount(LogSession.KeptFiles); + logs.Should().Contain(f => f!.EndsWith("-status.log")); + logs.Should().NotContain("20200101-000000-000-old.log"); + File.Exists(Path.Combine(dir.Path, "notes.txt")).Should().BeTrue(); + } + + [Fact] + public void Events_LoggedBeforeOpen_ReachTheFile() + { + using var dir = new TempDirectory(); + string path; + using (var session = LogSession.Create(dir.Path, LogLevel.Information, consoleWriter: null)) + { + session.Logger.Information("early event"); + session.Open("status"); + session.Logger.Information("late event"); + path = session.FilePath!; + } + + File.ReadAllText(path).Should().Contain("early event").And.Contain("late event"); + } + + [Fact] + public void EnsureOpen_WhenNoCommandWasParsed_FallsBackToTool() + { + using var dir = new TempDirectory(); + using var session = LogSession.Create(dir.Path, LogLevel.Information, consoleWriter: null); + + Path.GetFileName(session.EnsureOpen()).Should().EndWith("-tool.log"); + } + + [Fact] + public void File_TakesEveryLevel_WithoutAConsoleSink() + { + using var dir = new TempDirectory(); + string path; + using (var session = LogSession.Create(dir.Path, LogLevel.Information, consoleWriter: null)) + { + session.Open("status"); + session.Logger.Debug("debug for the file"); + path = session.FilePath!; + } + + File.ReadAllText(path).Should().Contain("debug for the file"); + } + + [Theory] + [InlineData(LogLevel.Debug, true)] + [InlineData(LogLevel.Information, false)] + public void Console_WhenEnabled_IsGatedByItsLevel(LogLevel consoleLevel, bool debugVisible) + { + using var dir = new TempDirectory(); + var console = new StringWriter(); + using (var session = LogSession.Create(dir.Path, consoleLevel, console)) + { + session.Open("status"); + session.Logger.Information("info line"); + session.Logger.Debug("debug line"); + } + + console.ToString().Should().Contain("info line"); + console.ToString().Contains("debug line").Should().Be(debugVisible); + } + + [Fact] + public void WriteFailureFooter_NamesTheLogFile() + { + var error = new StringWriter(); + + LogSession.WriteFailureFooter(error, "/logs/x-status.log"); + + error.ToString().Should().Contain("/logs/x-status.log").And.Contain("failed"); + } +} diff --git a/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/ProgramTests.cs b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/ProgramTests.cs new file mode 100644 index 0000000..be3431e --- /dev/null +++ b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/ProgramTests.cs @@ -0,0 +1,99 @@ +using System.Text.Json; +using DragoAnt.MSBuildKit.Manager.Cli; + +namespace DragoAnt.MSBuildKit.Manager.Tests; + +public sealed class ProgramTests +{ + private sealed record Run(int ExitCode, string Out, string Error); + + private static Run Invoke(TempDirectory logs, bool errorIsTerminal, params string[] args) + { + var console = new ToolConsole(new StringWriter(), new StringWriter(), errorIsTerminal); + var exitCode = Program.Run(args, console, logs.Path); + return new Run(exitCode, console.Out.ToString()!, console.Error.ToString()!); + } + + [Fact] + public void StatusJson_OnATerminal_KeepsTheLogoOffStdout() + { + using var logs = new TempDirectory(); + + var run = Invoke(logs, errorIsTerminal: true, "status", "--json"); + + run.ExitCode.Should().Be(0, run.Error); + using var json = JsonDocument.Parse(run.Out); + var tool = json.RootElement.GetProperty("tool"); + tool.GetProperty("name").GetString().Should().Be("mskit-manager"); + tool.GetProperty("version").GetString().Should().Be(ToolInfo.Version); + run.Error.Should().Contain("mskit-manager " + ToolInfo.Version); + } + + [Fact] + public void Logo_IsSkippedWithNoLogo() + { + using var logs = new TempDirectory(); + + var run = Invoke(logs, errorIsTerminal: true, "--no-logo", "status", "--json"); + + run.ExitCode.Should().Be(0, run.Error); + run.Error.Should().BeEmpty(); + } + + [Fact] + public void Logo_IsSkippedWhenStderrIsRedirected() + { + using var logs = new TempDirectory(); + + var run = Invoke(logs, errorIsTerminal: false, "status", "--json"); + + run.ExitCode.Should().Be(0, run.Error); + run.Error.Should().BeEmpty(); + } + + [Fact] + public void Status_WithoutJson_PrintsTheVersionLine() + { + using var logs = new TempDirectory(); + + var run = Invoke(logs, errorIsTerminal: false, "status"); + + run.ExitCode.Should().Be(0, run.Error); + run.Out.Trim().Should().Be("mskit-manager " + ToolInfo.Version); + } + + [Fact] + public void LogFile_IsNamedAfterTheParsedCommand_AndRecordsTheRun() + { + using var logs = new TempDirectory(); + + Invoke(logs, errorIsTerminal: false, "--diagnostic", "status", "--json"); + + var file = Directory.GetFiles(logs.Path, "*.log").Should().ContainSingle().Subject; + Path.GetFileName(file).Should().EndWith("-status.log"); + File.ReadAllText(file).Should().Contain(ToolInfo.Version); + } + + [Fact] + public void Help_ListsTheStatusCommand() + { + using var logs = new TempDirectory(); + + var run = Invoke(logs, errorIsTerminal: false, "--help"); + + run.ExitCode.Should().Be(0, run.Error); + run.Out.Should().Contain("status"); + } + + [Fact] + public void UnknownCommand_IsAUsageErrorWithExitCode2() + { + using var logs = new TempDirectory(); + + var run = Invoke(logs, errorIsTerminal: false, "no-such-command"); + + run.ExitCode.Should().Be(2); + run.Error.Should().Contain("no-such-command"); + run.Out.Should().BeEmpty(); + } +} diff --git a/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/TempDirectory.cs b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/TempDirectory.cs new file mode 100644 index 0000000..9ed0c7e --- /dev/null +++ b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/TempDirectory.cs @@ -0,0 +1,21 @@ +namespace DragoAnt.MSBuildKit.Manager.Tests; + +internal sealed class TempDirectory : IDisposable +{ + public string Path { get; } = Directory.CreateDirectory( + System.IO.Path.Combine(System.IO.Path.GetTempPath(), "mskit-manager-tests", Guid.NewGuid().ToString("N"))).FullName; + + public void Dispose() + { + try + { + Directory.Delete(Path, recursive: true); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } +}